diff --git a/docs/explore-notes/external-mcp.md b/docs/explore-notes/external-mcp.md index f74131a0..9795f50d 100644 --- a/docs/explore-notes/external-mcp.md +++ b/docs/explore-notes/external-mcp.md @@ -32,7 +32,14 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` `ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a list of verbosely-described tasks from blowing past the response size limit by default. -3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so +3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate + lives there as `const` strings. Rules: the first sentence says what the tool does *and* when + to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind + return-shape prose); parameters are documented with `[Description]` **on the parameter**, not + in the tool description; result fields appear only where the caller must branch on them + before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or + "since this feature was introduced" history. Not test-enforced — review it in PRs. +4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException` / `ArgumentException` messages survive as `McpException` — otherwise the SDK's catch-all replaces any non-`McpException` with a generic *"An error occurred invoking 'X'."* @@ -42,8 +49,9 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` ### `ExternalMcpService` — task CRUD, execution, git Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, -`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, -`CancelTask`, `DeleteTask`. +`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`. +(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s +description is now the canonical place for what each status means.) Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, `PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`. diff --git a/docs/explore-notes/usage-monitoring.md b/docs/explore-notes/usage-monitoring.md index 9b37575a..3d913ee4 100644 --- a/docs/explore-notes/usage-monitoring.md +++ b/docs/explore-notes/usage-monitoring.md @@ -1,7 +1,8 @@ # Usage monitoring, gate & throttle > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. -> Last verified against commit `f6cb825` (2026-08-05). +> Last verified against commit `f6cb825` (2026-08-05), plus the uncommitted per-bucket-throttle / +> draggable-gauge change of 2026-08-06 (this note already describes that newer state). > Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs` > Stable structure only (no line numbers). See docs/explore-notes/README.md. @@ -52,21 +53,26 @@ on block / Info on resume) exactly **once per change**, not every tick. ## The throttle (staged parallelism) -`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, -gateFiveHourPct, gateSevenDayPct)` — pure static, no state. +`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct, +sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the +per-bucket triple (same file). -Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65). -Whichever of 5h/7d is **more utilized** decides the stage: +Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` / +`usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill +at very different rates. Each bucket is staged independently and the **strictest** bucket wins — +not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one +that throttles: -| Utilization | Effective slots | +| Utilization (per bucket) | That bucket's slots | |---|---| | below soft | full configured `max_parallel_executions` | | `>= softPct` | capped at 2 | | `>= hardPct` | capped at 1 | -| `>=` either gate threshold | 0 — same hard block as `UsageGate` | +| `>= gatePct` | 0 — same hard block as `UsageGate` | -A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with -`UsageGate`'s hard block because both read the same gate thresholds — change one, change both. +A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never +throttles. The `0` return is deliberately kept in sync with `UsageGate`'s hard block because both +read the same gate thresholds — change one, change both. Only **new** slot fills are affected; a run already occupying a slot when the stage tightens runs to completion. Same fail-open policy: no snapshot means no throttling. @@ -108,6 +114,12 @@ A missing/unreadable transcript leaves all four fields `null`; it never fails th Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs Other), deduped by `requestId`, with a per-file length+mtime cache. +`ReadAsync` **skips any file whose mtime predates the window start minus one day** — it cannot hold +a record inside the range, and the full history is large (measured 2026-08-06: 501 files / 230 MB / +77k lines ≈ 1.7 s to parse cold; a 7-day range touches ~190 files / ~106 MB). The one-day slack +absorbs local-vs-UTC skew between mtime and record timestamps. `ReadSessionTotalsAsync` is +unaffected — it looks up a single `{sessionId}.jsonl`. + ``-model lines are skipped **everywhere** — they are not real API calls. ## UI surfaces @@ -117,6 +129,25 @@ Other), deduped by `requestId`, with a per-file length+mtime cache. `IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive: **blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket. + The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before + loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because + the first `GetModelUsage` per worker process scans the whole transcript history. +- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate). + `UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work; + the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps + soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's + `CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the + dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only. +- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches + match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each + with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag` + (`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's + `CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same** + `UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited + stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the + `NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`. + Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag + doesn't replace the bound instance. - **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in `UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` / `seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back @@ -138,5 +169,13 @@ Other), deduped by `requestId`, with a per-file length+mtime cache. ## Settings columns `app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90), -`usage_throttle_soft_pct` / `usage_throttle_hard_pct` (50/65). All four clamped 0..100 by -`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`. +`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per +bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce +soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config +degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` / +`usage_poll_interval_idle_seconds`. + +The gate percentages are editable in **two** places that both write the same `app_settings` row: +Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are +gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings +can't reset a dragged value. diff --git a/docs/explore-notes/worker-task-pipeline.md b/docs/explore-notes/worker-task-pipeline.md index 743b46de..306ed028 100644 --- a/docs/explore-notes/worker-task-pipeline.md +++ b/docs/explore-notes/worker-task-pipeline.md @@ -124,7 +124,7 @@ read-only "## Reference files" section. - `TaskMergeService` — conflict resolution for worktree merges. **Hub/** -- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…). +- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/WorktreeUpdated…). - `WorkerHub` — SignalR hub + client methods. **Agents/** diff --git a/docs/superpowers/plans/2026-08-07-handler-run-links.md b/docs/superpowers/plans/2026-08-07-handler-run-links.md new file mode 100644 index 00000000..141b4672 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-handler-run-links.md @@ -0,0 +1,939 @@ +# Handler-Run Links Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL. + +**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path. + +**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit. + +**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md` + +--- + +## File Structure + +**Modified:** +- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property +- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index +- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — `SetHandlerTaskIdAsync` +- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task +- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence +- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border +- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `HandlerBadgeBrush` + `Border.badge.handler` +- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `tasks.badgeHandler`, `tasks.handlerTip` +- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `HandledTasks` collection, loader, clear, refresh +- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel +- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs + +**Created:** +- `src/ClaudeDo.Data/Migrations/_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated +- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` +- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` +- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` + +--- + +## Task 1: Data — `HandlerTaskId` column and migration + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61` +- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131` +- Create: `src/ClaudeDo.Data/Migrations/_AddHandlerTaskId.cs` (generated) + +- [ ] **Step 1: Add the property** + +In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add: + +```csharp + + // Id of the "list handler" run task that processed this task ("Let Claude handle it"). + // 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately + // NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering. + // No FK: a deleted handler task must not cascade into the tasks it merely touched. + public string? HandlerTaskId { get; set; } +``` + +- [ ] **Step 2: Map the column and index it** + +In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line +`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add: + +```csharp + builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id"); +``` + +At the end of `Configure`, after the line +`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add: + +```csharp + builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id"); +``` + +Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less. + +- [ ] **Step 3: Generate the migration** + +Run from the repo root: + +```bash +dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +``` + +Expected: creates `src/ClaudeDo.Data/Migrations/_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry. + +If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring +`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add +`Property("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");` +plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`. + +- [ ] **Step 4: Build** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release` +Expected: `Build succeeded`, 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations +git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run" +``` + +⚠️ Always commit with explicit paths (`git commit -- `), never a bare `git commit` — the +main checkout is shared with concurrent sessions. + +--- + +## Task 2: Data — `SetHandlerTaskIdAsync` repository method + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`) +- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Repositories; + +/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it" +/// run processed, so the handler task's detail pane can list them after the run. +public sealed class TaskRepositoryHandlerLinkTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public TaskRepositoryHandlerLinkTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task CreateListAsync() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity + { + Id = listId, + Name = "Test List", + CreatedAt = DateTime.UtcNow, + }); + return listId; + } + + private async Task AddTaskAsync(string listId) + { + var id = Guid.NewGuid().ToString(); + await _tasks.AddAsync(new TaskEntity + { + Id = id, + ListId = listId, + Title = "T", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + }); + return id; + } + + [Fact] + public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks() + { + var listId = await CreateListAsync(); + var a = await AddTaskAsync(listId); + var b = await AddTaskAsync(listId); + var handlerId = await AddTaskAsync(listId); + + var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId); + + Assert.Equal(2, affected); + Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId); + Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId); + Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId); + } + + [Fact] + public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds() + { + var listId = await CreateListAsync(); + var a = await AddTaskAsync(listId); + var handlerId = await AddTaskAsync(listId); + + var affected = await _tasks.SetHandlerTaskIdAsync( + new[] { a, "does-not-exist" }, handlerId); + + Assert.Equal(1, affected); + Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId); + } + + [Fact] + public async Task SetHandlerTaskIdAsync_SecondRunOverwrites() + { + var listId = await CreateListAsync(); + var a = await AddTaskAsync(listId); + var firstHandler = await AddTaskAsync(listId); + var secondHandler = await AddTaskAsync(listId); + + await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler); + await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler); + + Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId); + } + + [Fact] + public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp() + { + var listId = await CreateListAsync(); + var handlerId = await AddTaskAsync(listId); + + var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty(), handlerId); + + Assert.Equal(0, affected); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"` +Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`. + +- [ ] **Step 3: Implement the method** + +In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add: + +```csharp + // Links the tasks a "list handler" run processed back to the handler's own task, so the + // handler's detail pane can list them after the run. Stamped from the user's selection at + // creation time -- that way tasks the handler later cancels as duplicates stay visible. + // Unknown ids are silently skipped. Returns the number of rows actually stamped. + public async Task SetHandlerTaskIdAsync( + IReadOnlyList taskIds, + string handlerTaskId, + CancellationToken ct = default) + { + if (taskIds.Count == 0) return 0; + + var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList(); + if (ids.Count == 0) return 0; + + return await _context.Tasks + .Where(t => ids.Contains(t.Id)) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.HandlerTaskId, handlerTaskId), ct); + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"` +Expected: `Passed! - Failed: 0, Passed: 4`. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs +git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks" +``` + +--- + +## Task 3: Worker — stamp the selection when the handler task is created + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`) + +Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList taskIds` — the UI → +`IWorkerClient` → `WorkerHub` chain needs **no** change. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test: + +```csharp + [Fact] + public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha"); + var t1 = Guid.NewGuid().ToString(); + var t2 = Guid.NewGuid().ToString(); + await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); + await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); + + var svc = BuildService(); + var handlerId = await svc.CreateMergeHelperTaskAsync( + new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None); + + using var readCtx = _db.CreateContext(); + var tasks = new TaskRepository(readCtx); + Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId); + Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId); + // The handler never links to itself. + Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"` +Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`. + +- [ ] **Step 3: Stamp the selection** + +In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace: + +```csharp + await taskRepo.AddAsync(handlerTask, ct); + + return handlerTask.Id; +``` + +with: + +```csharp + await taskRepo.AddAsync(handlerTask, ct); + + // Link the selection back to this run BEFORE the session starts: the handler cancels + // duplicates in phase 1, and those still belong in the "what was this run supposed to do" + // list. Stamping later (e.g. at handoff) would lose them. + await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct); + + return handlerTask.Id; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"` +Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task" +``` + +--- + +## Task 4: Ui — HANDLER badge instead of MANUAL + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329` +- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144` +- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990` +- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`: + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on +/// it -- the HANDLER badge must win and MANUAL must disappear. +public class TaskRowViewModelHandlerBadgeTests +{ + [Fact] + public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge() + { + var row = new TaskRowViewModel { Id = "t1" }; + row.IsManual = true; + row.HandlerBaseCommit = "base123"; + + Assert.True(row.IsHandlerRun); + Assert.NotNull(row.HandlerBadge); + Assert.Null(row.ManualBadge); + } + + [Fact] + public void PlainManualTask_StillShowsManualBadge() + { + var row = new TaskRowViewModel { Id = "t2" }; + row.IsManual = true; + + Assert.False(row.IsHandlerRun); + Assert.Null(row.HandlerBadge); + Assert.NotNull(row.ManualBadge); + } + + [Fact] + public void UpdateFromEntity_MirrorsHandlerBaseCommit() + { + var row = new TaskRowViewModel { Id = "t3" }; + row.UpdateFromEntity(new TaskEntity + { + Id = "t3", + ListId = "l1", + Title = "List handler: Alpha", + Status = TaskStatus.Idle, + IsManual = true, + HandlerBaseCommit = "base123", + CreatedAt = DateTime.UtcNow, + }); + + Assert.Equal("base123", row.HandlerBaseCommit); + Assert.True(row.IsHandlerRun); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"` +Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`. + +- [ ] **Step 3: Add the properties** + +In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field +declaration (`[ObservableProperty] private bool _isManual;`), add: + +```csharp + // Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run + // host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL. + [ObservableProperty] private string? _handlerBaseCommit; +``` + +Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with: + +```csharp + public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit); + + public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null; + + // HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and + // "MANUAL" would read as a hand-written reminder. The two badges never show together. + public bool ShowManualBadge => IsManual && !IsHandlerRun; + + public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null; +``` + +Add a change hook next to the existing `OnIsManualChanged` partial method: + +```csharp + partial void OnHandlerBaseCommitChanged(string? value) + { + OnPropertyChanged(nameof(IsHandlerRun)); + OnPropertyChanged(nameof(HandlerBadge)); + OnPropertyChanged(nameof(ShowManualBadge)); + OnPropertyChanged(nameof(ManualBadge)); + } +``` + +Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add: + +```csharp + OnPropertyChanged(nameof(ShowManualBadge)); +``` + +In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add: + +```csharp + HandlerBaseCommit = t.HandlerBaseCommit; +``` + +Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line: + +```csharp + OnPropertyChanged(nameof(HandlerBadge)); + OnPropertyChanged(nameof(ManualBadge)); +``` + +- [ ] **Step 4: Add the locale keys** + +In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add: + +```json + "badgeHandler": "HANDLER", + "handlerTip": "Handler run — see the tasks it processed in the detail pane", +``` + +In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add: + +```json + "badgeHandler": "HANDLER", + "handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich", +``` + +- [ ] **Step 5: Add the badge style and brush** + +In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line +`` add: + +```xml + +``` + +After the existing `Border.badge.manual` style block add: + +```xml + + +``` + +- [ ] **Step 6: Render the badge** + +In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144): + +```xml + + + +``` + +with: + +```xml + + + + + + +``` + +Only the `IsVisible` binding changed on the manual border (`IsManual` → `ShowManualBadge`); the +handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"` +Expected: `Passed! - Failed: 0, Passed: 3`. + +Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release` +Expected: `Passed! - Failed: 0` (en/de key parity). + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Expected: `Build succeeded` — this compiles the AXAML. + +- [ ] **Step 8: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs +git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks" +``` + +--- + +## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833` +- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with +/// its id, including ones the handler cancelled as duplicates. +public class DetailsIslandHandledTasksTests : IDisposable +{ + private readonly string _dbPath; + + public DetailsIslandHandledTasksTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_test_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi + { + public Task> ListAsync(DateOnly day) => + Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => + Task.FromResult(null); + public Task UpdateAsync(string id, string text) => Task.CompletedTask; + public Task DeleteAsync(string id) => Task.CompletedTask; + } + + private sealed class FakeWorkerClient : StubWorkerClient + { + public override bool IsConnected => true; + } + + private DetailsIslandViewModel BuildVm() + { + var factory = new TestDbFactory(NewContext); + return new DetailsIslandViewModel( + factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator()); + } + + [Fact] + public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus() + { + const string listId = "list-1"; + const string handlerId = "handler-task-1"; + + await using (var ctx = NewContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = handlerId, ListId = listId, Title = "List handler: L", + Status = TaskStatus.WaitingForReview, IsManual = true, + HandlerBaseCommit = "base123", HandlerHeadCommit = "head456", + CreatedAt = DateTime.UtcNow, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = "done-1", ListId = listId, Title = "Merged task", + Status = TaskStatus.Done, HandlerTaskId = handlerId, + SortOrder = 0, CreatedAt = DateTime.UtcNow, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled", + Status = TaskStatus.Cancelled, HandlerTaskId = handlerId, + SortOrder = 1, CreatedAt = DateTime.UtcNow, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = "unrelated-1", ListId = listId, Title = "Not part of the run", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview }); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0) + await Task.Delay(20); + + Assert.Equal(2, vm.HandledTasks.Count); + Assert.True(vm.HasHandledTasks); + Assert.Equal("Merged task", vm.HandledTasks[0].Title); + Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status); + Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status); + Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1"); + } + + [Fact] + public async Task Bind_PlainTask_HasNoHandledTasks() + { + const string listId = "list-1"; + const string taskId = "plain-1"; + + await using (var ctx = NewContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "Plain", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle }); + await Task.Delay(300); + + Assert.Empty(vm.HandledTasks); + Assert.False(vm.HasHandledTasks); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"` +Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`. + +- [ ] **Step 3: Add the collection** + +In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line +`public ObservableCollection ChildOutcomes { get; } = new();` add: + +```csharp + // Tasks a "list handler" run processed ("Let Claude handle it"), linked via + // TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the + // planning/improvement parent's children and feeds the merge card's combined diff, which a + // handler run must not touch (it commits straight to the list's working dir). + public ObservableCollection HandledTasks { get; } = new(); +``` + +After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add: + +```csharp + public bool HasHandledTasks => HandledTasks.Count > 0; +``` + +- [ ] **Step 4: Clear it on rebind** + +In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add: + +```csharp + HandledTasks.Clear(); +``` + +and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add: + +```csharp + OnPropertyChanged(nameof(HasHandledTasks)); +``` + +- [ ] **Step 5: Load it on bind** + +In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add: + +```csharp + await LoadHandledTasksAsync(row.Id, ct); +``` + +Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`: + +```csharp + // Tasks stamped with this handler run's id. Ordered like the task list itself so the panel + // reads in the same order the user picked them. + private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct) + { + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var handled = await ctx.Tasks + .AsNoTracking() + .Include(t => t.Worktree) + .Where(t => t.HandlerTaskId == handlerTaskId) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .ToListAsync(ct); + ct.ThrowIfCancellationRequested(); + if (handled.Count == 0) return; + + HandledTasks.Clear(); + foreach (var h in handled) + HandledTasks.Add(new ChildOutcomeRowViewModel + { + Id = h.Id, + Title = h.Title, + Status = h.Status, + RoadblockCount = h.RoadblockCount, + WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active, + }); + OnPropertyChanged(nameof(HasHandledTasks)); + } + catch (OperationCanceledException) { } + catch { /* best-effort */ } + } +``` + +- [ ] **Step 6: Keep the rows live** + +In the same file, in `RefreshChildOutcomeAsync`, replace: + +```csharp + var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId); + if (row is null) return; +``` + +with: + +```csharp + // The same refresh serves both lists: a planning parent's children and a handler run's + // handled tasks. Only one of them can hold a given id. + var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId) + ?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId); + if (row is null) return; +``` + +- [ ] **Step 7: Render the panel** + +In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing +`` of the existing `` block, add: + +```xml + + + + + + + + + + + + + + + +``` + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"` +Expected: `Passed! - Failed: 0, Passed: 2`. + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Expected: `Build succeeded`. + +- [ ] **Step 9: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs +git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane" +``` + +--- + +## Task 6: Full verification and docs + +**Files:** +- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list) +- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets) +- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section) + +- [ ] **Step 1: Run every affected test project** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` + +Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile, +it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test. + +- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`** + +In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after +`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing +`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet: + +```markdown + - `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree. +``` + +- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`** + +In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add +`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`. + +In the `TaskRowViewModel` sentence, after the `IsManual` clause, add +`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`. + +- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`** + +In the "The host task and its commit range" section, add after the existing description: + +```markdown +`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task +(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail +pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates. +The handler never links to itself. +``` + +Bump that note's "verified against" commit line to the current HEAD. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md +git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link" +``` + +- [ ] **Step 6: Report the visual-verification gap** + +The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika: + +1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder. +2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll). +3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate. diff --git a/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md b/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md index 65ec1793..199d3edd 100644 --- a/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md +++ b/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md @@ -869,9 +869,18 @@ git commit -m "fix(worker): broadcast TaskUpdated for tasks imported from the on --- -### Task 7: `TaskUpdated` nach dem Anlegen der List-Handler-Task +### Task 7: ~~`TaskUpdated` nach dem Anlegen der List-Handler-Task~~ — ENTFÄLLT -`InteractiveLaunchSpecService` legt die Handler-Task an (`InteractiveLaunchSpecService.cs:447`) ohne Broadcast. Einzige weitere Aufrufstelle des Konstruktors ist `tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs:64`. +**Bei der Umsetzung am 2026-08-07 verworfen. Prämisse war falsch, kein Code geändert.** + +`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` (`:447`) broadcastet selbst nichts — das stimmte. Aber ihr **einziger** Produktions-Aufrufer, `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`), sendet unmittelbar danach `Clients.All.SendAsync("TaskUpdated", taskId)`. Das kam mit Commit `c07c1f7` (2026-08-05) und ist durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts` abgesichert. Der UI-Pfad (`MissionControlViewModel` → `WorkerClient.CreateMergeHelperTaskAsync` → Hub) führt ausschließlich über diesen Aufrufer. + +Den Broadcast zusätzlich in den Service zu legen hätte ihn **verdoppelt**. Ihn dorthin zu *verschieben* wäre ein reiner Konsistenz-Refactor ohne Verhaltensänderung — bewusst nicht gemacht. + +Lehre für den Rest des Plans: Ein DB-Write ohne unmittelbar folgenden Broadcast ist erst dann ein Loch, wenn auch **alle Aufrufer** geprüft sind. Bei `WorktreeManager.cs:103` (Task 5) war das Loch echt, hier nicht. + +
+Ursprünglicher Task-Text (nicht umgesetzt) **Files:** - Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` @@ -952,6 +961,8 @@ git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeD git commit -m "fix(worker): broadcast TaskUpdated when the list-handler task is created" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs ``` +
+ --- ### Task 8: Totes Event `RunCreated` entfernen diff --git a/docs/superpowers/specs/2026-08-07-handler-run-links-design.md b/docs/superpowers/specs/2026-08-07-handler-run-links-design.md new file mode 100644 index 00000000..3702d674 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-handler-run-links-design.md @@ -0,0 +1,169 @@ +# Handler-Run: Verknüpfung zu den behandelten Tasks + +**Date:** 2026-08-07 +**Status:** Design approved (Mika), implementation pending +**Verified against:** commit `c792765` + +## Problem + +Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`, +`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run +behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im +Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`) +bekommt `survivingTaskIds` als flüchtige Liste. + +Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen, +*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler +in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld. + +Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt — +irreführend, denn es ist kein manueller Reminder. + +## Ist-Zustand + +### Es gibt kein Task-Kind + +`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen: + +| Feld | Bedeutung | +|---|---| +| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn | +| `ParentTaskId` | Kind einer Planning-/Improvement-Session | +| `PlanningPhase` | Planning-Parent | +| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es | +| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) | + +Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert. + +### `ParentTaskId` ist belegt + +`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es +für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild` +(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile +im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler +schieben und mit echten Planning-Kindern kollidieren. + +### Badge-Infrastruktur existiert + +`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über +`Border Classes="badge "`. Basis-Style und Varianten liegen in +`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in +`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164). + +### Kinder-Panel existiert + +`DetailsIslandViewModel.LoadChildOutcomesAsync` +(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt +`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile +Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über +`TaskUpdated`/`WorktreeUpdated` (`:814-833`). + +## Entscheidungen + +| Frage | Entscheidung | Begründung | +|---|---|---| +| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. | +| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik | +| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. | +| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. | +| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. | + +## Design + +### 1. Daten + +Neue nullable Spalte auf `TaskEntity`: + +```csharp +/// Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner). +public string? HandlerTaskId { get; set; } +``` + +Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint +(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks +nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`. + +Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n). + +### 2. Schreiben + +Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync` → +`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) → +`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt +eine neue Repository-Methode die Zuordnung in einem Batch-Update: + +```csharp +Task SetHandlerTaskIdAsync(IReadOnlyList taskIds, string handlerTaskId, CancellationToken ct); +``` + +Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden +still übersprungen. + +### 3. Badge + +`TaskRowViewModel`: + +```csharp +public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit); +public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null; +public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null; +``` + +`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden. +HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig. + +In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit +`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante +mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark. + +Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft): + +- `tasks.badgeHandler` — "HANDLER" / "HANDLER" +- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die + Tasks, die er bearbeitet hat" + +### 4. Anzeige + +Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden +Kinder-Panel: + +- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync` + mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste. +- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount, + WorktreeState) — keine neue Row-Klasse. +- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende + `RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert. +- Sichtbar nur wenn `HandledTasks.Count > 0`. +- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige + (Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task + springen" wäre neues Verhalten und ist hier nicht enthalten. + +### 5. Fehlerfälle + +- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben + normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig. +- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks). +- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar. + +## Tests + +| Ebene | Test | +|---|---| +| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung | +| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst | +| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) | +| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` | +| Localization | Parität en/de — deckt der bestehende Test automatisch ab | + +## Bewusst nicht enthalten + +- Kein `TaskKind`-Enum. +- Keine n:m-Historie über mehrere Runs. +- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis. +- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task. +- Kein Badge auf den *behandelten* Tasks. + +## Offen + +- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im + Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll). diff --git a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md index 01275a0e..0c2fc220 100644 --- a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md +++ b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md @@ -22,12 +22,14 @@ Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der ei |---|---|---| | 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` | | 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218–238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` | -| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131`, `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:447` | +| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131` | + +**Korrektur (2026-08-07, bei der Umsetzung gefunden):** `InteractiveLaunchSpecService.cs:447` stand hier ursprünglich als drittes Loch. Das war falsch. Die Service-Methode broadcastet zwar selbst nicht, aber ihr einziger Produktions-Aufrufer `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`) sendet direkt danach `TaskUpdated` — seit Commit `c07c1f7` vom 2026-08-05, abgesichert durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts`. Der ursprüngliche Befund hatte den DB-Write gesehen, aber den Aufrufer nicht geprüft. Ein Broadcast im Service wäre ein Duplikat gewesen. Dazu zwei kleinere Befunde: - **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben. -- **Kein Busy-Timeout konfiguriert.** Die Connection-Strings beider Prozesse sind blanke `Data Source=…` (`src/ClaudeDo.App/Program.cs:100-101`, `src/ClaudeDo.Worker/Program.cs:60-61`). Ohne Timeout schlägt ein seltener `SQLITE_BUSY` sofort als Exception durch, statt kurz zu warten — das erhöht die Wahrscheinlichkeit von Loch 1. Achtung: `PRAGMA busy_timeout` ist **per Connection** und wird — anders als `journal_mode=WAL` — *nicht* in der DB-Datei persistiert. Es in `ClaudeDoDbContext.MigrateAndConfigure` zu setzen würde nur die Startup-Connection betreffen und wäre wirkungslos; es gehört in den Connection-String (`Default Timeout=`), den Microsoft.Data.Sqlite auf den Busy-Handler abbildet. +- ~~**Kein Busy-Timeout konfiguriert.**~~ **Widerlegt (2026-08-07, empirisch geprüft).** Die Vermutung war, die blanken Connection-Strings (`src/ClaudeDo.App/Program.cs:95`, `src/ClaudeDo.Worker/Program.cs:61`) ließen einen `SQLITE_BUSY` sofort durchschlagen. Das stimmt nicht: Microsoft.Data.Sqlite 8.0.11 setzt `DefaultTimeout` **von sich aus auf 30 Sekunden**, mit oder ohne das Keyword — gemessen an `SqliteConnectionStringBuilder("Data Source=x.db").DefaultTimeout` → `30`, ebenso `SqliteConnection.DefaultTimeout` und `SqliteCommand.CommandTimeout`. Ein Contention-Test (Writer hält 2s, zweiter Writer parallel) zeigt, dass der zweite wartet und nach ~2030 ms durchkommt, statt zu werfen. Der ursprünglich dafür gemachte Commit `f62dbb9` war ein No-op mit irreführendem Kommentar und wurde mit `ac58679` zurückgenommen. Die tatsächliche Absicherung gegen transiente Lesefehler leistet der Retry im Delta-Pfad, nicht ein Timeout. - **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI. ### Performance: der Engpass ist das Rendering, nicht die Datenbank @@ -95,11 +97,9 @@ Unabhängig von Phase 2 und 3, kann sofort starten. | Fix | Ort | |---|---| | `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` | -| `Default Timeout=30` in beide Connection-Strings (nicht als PRAGMA — siehe Analyse) | `App/Program.cs:100-101`, `Worker/Program.cs:60-61` | | Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` | | `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` | | `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` | -| `TaskUpdated` nach dem Insert broadcasten | `Runner/InteractiveLaunchSpecService.cs:447` | | Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` | | `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` | diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index e83d865f..357f20d1 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -29,7 +29,7 @@ Beyond the basics it carries: | `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. | | `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. | | `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. | -| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. | +| `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. | | `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. | | `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. | | `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. | diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index 13404a7e..dbf294ba 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -55,10 +55,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.UsageGateSevenDayPct) .HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90); - builder.Property(s => s.UsageThrottleSoftPct) - .HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50); - builder.Property(s => s.UsageThrottleHardPct) - .HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65); + builder.Property(s => s.UsageThrottleFiveHourSoftPct) + .HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50); + builder.Property(s => s.UsageThrottleFiveHourHardPct) + .HasColumnName("usage_throttle_five_hour_hard_pct").IsRequired().HasDefaultValue(65); + builder.Property(s => s.UsageThrottleSevenDaySoftPct) + .HasColumnName("usage_throttle_seven_day_soft_pct").IsRequired().HasDefaultValue(50); + builder.Property(s => s.UsageThrottleSevenDayHardPct) + .HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); } diff --git a/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.Designer.cs b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.Designer.cs new file mode 100644 index 00000000..545c4758 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.Designer.cs @@ -0,0 +1,883 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260806141710_SplitUsageThrottlePerBucket")] + partial class SplitUsageThrottlePerBucket + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.cs b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.cs new file mode 100644 index 00000000..25f18668 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260806141710_SplitUsageThrottlePerBucket.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class SplitUsageThrottlePerBucket : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "usage_throttle_soft_pct", + table: "app_settings", + newName: "usage_throttle_seven_day_soft_pct"); + + migrationBuilder.RenameColumn( + name: "usage_throttle_hard_pct", + table: "app_settings", + newName: "usage_throttle_seven_day_hard_pct"); + + migrationBuilder.AddColumn( + name: "usage_throttle_five_hour_hard_pct", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 65); + + migrationBuilder.AddColumn( + name: "usage_throttle_five_hour_soft_pct", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 50); + + // The old single soft/hard pair was compared against whichever bucket was more utilized, + // so carrying it into BOTH buckets keeps an existing install behaving exactly as before + // the split — the rename above already preserved it for the 7d side. + migrationBuilder.Sql( + """ + UPDATE app_settings + SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct, + usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "usage_throttle_five_hour_hard_pct", + table: "app_settings"); + + migrationBuilder.DropColumn( + name: "usage_throttle_five_hour_soft_pct", + table: "app_settings"); + + migrationBuilder.RenameColumn( + name: "usage_throttle_seven_day_soft_pct", + table: "app_settings", + newName: "usage_throttle_soft_pct"); + + migrationBuilder.RenameColumn( + name: "usage_throttle_seven_day_hard_pct", + table: "app_settings", + newName: "usage_throttle_hard_pct"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index ef7c07ad..0137ba73 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -106,17 +106,29 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue(90) .HasColumnName("usage_gate_seven_day_pct"); - b.Property("UsageThrottleHardPct") + b.Property("UsageThrottleFiveHourHardPct") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(65) - .HasColumnName("usage_throttle_hard_pct"); + .HasColumnName("usage_throttle_five_hour_hard_pct"); - b.Property("UsageThrottleSoftPct") + b.Property("UsageThrottleFiveHourSoftPct") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(50) - .HasColumnName("usage_throttle_soft_pct"); + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); b.Property("WorktreeAutoCleanupDays") .ValueGeneratedOnAdd() @@ -155,8 +167,10 @@ namespace ClaudeDo.Data.Migrations StandupWeekday = 3, UsageGateFiveHourPct = 80, UsageGateSevenDayPct = 90, - UsageThrottleHardPct = 65, - UsageThrottleSoftPct = 50, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupEnabled = false, WorktreeStrategy = "sibling" diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index 39cf67cf..122344bf 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -43,8 +43,11 @@ public sealed class AppSettingsEntity public int UsageGateFiveHourPct { get; set; } = 80; public int UsageGateSevenDayPct { get; set; } = 90; - // Percentage of the 5h/7d Claude usage window at which the queue starts throttling - // parallelism ahead of the hard gate above. 0 = that stage off. - public int UsageThrottleSoftPct { get; set; } = 50; - public int UsageThrottleHardPct { get; set; } = 65; + // Percentage at which the queue starts throttling parallelism ahead of the hard gate above. + // Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps + // parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket. + public int UsageThrottleFiveHourSoftPct { get; set; } = 50; + public int UsageThrottleFiveHourHardPct { get; set; } = 65; + public int UsageThrottleSevenDaySoftPct { get; set; } = 50; + public int UsageThrottleSevenDayHardPct { get; set; } = 65; } diff --git a/src/ClaudeDo.Data/Models/ModelRegistry.cs b/src/ClaudeDo.Data/Models/ModelRegistry.cs index dd2f3cc1..8d3ea34f 100644 --- a/src/ClaudeDo.Data/Models/ModelRegistry.cs +++ b/src/ClaudeDo.Data/Models/ModelRegistry.cs @@ -5,7 +5,7 @@ public static class ModelRegistry public static readonly IReadOnlyList Aliases = new[] { "sonnet", "opus", "haiku", "fable" }; /// Model aliases ordered cheapest → most capable. Single source for prompt cost guidance. - public static readonly IReadOnlyList ByCostAscending = new[] { "haiku", "sonnet", "opus" }; + public static readonly IReadOnlyList ByCostAscending = new[] { "haiku", "sonnet", "opus", "fable" }; public const string DefaultAlias = "sonnet"; public const string PlanningAlias = "opus"; diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index 7461389e..e35bb1ca 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -88,8 +88,10 @@ public sealed class AppSettingsRepository row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills; row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100); row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100); - row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100); - row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100); + row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 0, 100); + row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100); + row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100); + row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100); await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 9fe0e41f..5aa6b39c 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -435,6 +435,10 @@ "gateBlockedFormat": "Queue pausiert — {0}", "throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})", "resetIn": "Reset in {0}", + "dragHint": "Marker ziehen oder Wert unten eintragen.", + "legendSoft": "Soft · 2 Slots", + "legendHard": "Hard · 1 Slot", + "legendGate": "Gate · Pause", "gaugeSession": "Session (5 Std.)", "gaugeWeeklyAll": "Woche (alle Modelle)", "gaugeWeeklyScopedFormat": "Woche ({0})", @@ -659,7 +663,8 @@ "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "usageMonitor": { "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}", - "refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}" + "refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}", + "thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 328d7333..41213be1 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -435,6 +435,10 @@ "gateBlockedFormat": "Queue paused — {0}", "throttleFormat": "Queue throttled: {0}/{1} slots ({2})", "resetIn": "Reset in {0}", + "dragHint": "Drag a marker, or type the value below.", + "legendSoft": "Soft · 2 slots", + "legendHard": "Hard · 1 slot", + "legendGate": "Gate · pause", "gaugeSession": "Session (5h)", "gaugeWeeklyAll": "Week (all models)", "gaugeWeeklyScopedFormat": "Week ({0})", @@ -659,7 +663,8 @@ "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "usageMonitor": { "loadFailed": "Couldn't load usage data: {0}", - "refreshFailed": "Couldn't refresh usage: {0}" + "refreshFailed": "Couldn't refresh usage: {0}", + "thresholdSaveFailed": "Couldn't save the threshold: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index e06e38f0..da6cea26 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -32,7 +32,7 @@ ViewModels/ Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, DescriptionStepsCard, WorkConsole; plus SessionTerminalView Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, InheritedBadge, - AgentConfigEditor + AgentConfigEditor, UsagePill, UsageGaugeBar Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml (component styles + the filled icon geometry library) ``` @@ -58,7 +58,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) | `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. | | `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. | | `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). | -| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. | +| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). | Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`, diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 1fd45b7e..33bb3c25 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -670,7 +670,12 @@ public sealed record AppSettingsDto( List? ModelPresets = null, int UsageGateFiveHourPct = 80, int UsageGateSevenDayPct = 90, - int MaxTurnsCeiling = 80); + int MaxTurnsCeiling = 80, + // Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings. + int UsageThrottleFiveHourSoftPct = 50, + int UsageThrottleFiveHourHardPct = 65, + int UsageThrottleSevenDaySoftPct = 50, + int UsageThrottleSevenDayHardPct = 65); // Per-model run defaults (effort + turn budget) edited in Settings → General. public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); @@ -764,7 +769,13 @@ public sealed record UsageSnapshotDto( string? LastError, int ConfiguredSlots, int EffectiveSlots, - string? ThrottleBucket); + string? ThrottleBucket, + // Throttle stages per bucket, drawn (and dragged) on the usage-monitor gauges. Defaults match + // the DB defaults so an older worker that doesn't send them yet still yields sane markers. + int ThrottleFiveHourSoftPct = 50, + int ThrottleFiveHourHardPct = 65, + int ThrottleSevenDaySoftPct = 50, + int ThrottleSevenDayHardPct = 65); public sealed record ModelUsageRowDto( DateOnly Date, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index c4bba7fc..6ccd42f9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -25,6 +25,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // pick the flag up (see SyncInteractiveSessions). private readonly HashSet _interactiveSessionIds = new(); private static readonly TaskListFilterRegistry _filters = new(); + // Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for + // one task can be in flight at once. Only the newest may write to the row. + private readonly Dictionary _deltaSeq = new(); + private long _deltaCounter; public event EventHandler? SelectionChanged; public event EventHandler? FocusAddTaskRequested; @@ -161,6 +165,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } private async void OnWorkerTaskUpdated(string taskId) + => await RefreshTaskFromWorkerAsync(taskId); + + // Awaitable so tests can drive it deterministically. One retry, then a full reload: + // a swallowed exception here used to leave the row on a stale status permanently. + internal async Task RefreshTaskFromWorkerAsync(string taskId) { var list = _currentList; if (list is null) return; @@ -174,54 +183,79 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable return; } + var seq = ++_deltaCounter; + _deltaSeq[taskId] = seq; + try { - await using var db = await _dbFactory.CreateDbContextAsync(); - var entity = await db.Tasks - .Include(t => t.List) - .Include(t => t.Worktree) - .FirstOrDefaultAsync(t => t.Id == taskId); - - // A parent transition (finalize/discard) broadcasts only the parent's id, but it - // changes its children's derived state — finalize flips them Draft→Planned, discard - // deletes them. The delta path below only touches the parent row and never recomputes - // the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted - // children, so reconcile the whole list when the updated task is (or owns) a subtree. - if (entity is not null && - (entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id))) - { - LoadForList(list); - return; - } - - var existing = Items.FirstOrDefault(r => r.Id == taskId); - - if (entity is null) - { - if (existing is not null) Items.Remove(existing); - } - else - { - var matches = TaskMatchesList(entity, list); - if (existing is not null && matches) existing.UpdateFromEntity(entity); - else if (existing is not null) Items.Remove(existing); - else if (matches) { LoadForList(list); return; } - else return; - } - - // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. - if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) - { - var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); - if (parent is not null) - parent.HasQueuedSubtasks = Items.Any(r => - r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); - } - - Regroup(); - UpdateSubtitle(); + await ApplyDeltaAsync(taskId, list, seq); } - catch { } + catch (Exception first) + { + System.Diagnostics.Debug.WriteLine( + $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); + try + { + await ApplyDeltaAsync(taskId, list, seq); + } + catch (Exception second) + { + System.Diagnostics.Debug.WriteLine( + $"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload"); + LoadForList(list); + } + } + } + + private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + var entity = await db.Tasks + .Include(t => t.List) + .Include(t => t.Worktree) + .FirstOrDefaultAsync(t => t.Id == taskId); + + // A newer refresh for this task started while we were reading — its result is fresher. + if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return; + + // A parent transition (finalize/discard) broadcasts only the parent's id, but it + // changes its children's derived state — finalize flips them Draft→Planned, discard + // deletes them. The delta path below only touches the parent row and never recomputes + // the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted + // children, so reconcile the whole list when the updated task is (or owns) a subtree. + if (entity is not null && + (entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id))) + { + LoadForList(list); + return; + } + + var existing = Items.FirstOrDefault(r => r.Id == taskId); + + if (entity is null) + { + if (existing is not null) Items.Remove(existing); + } + else + { + var matches = TaskMatchesList(entity, list); + if (existing is not null && matches) existing.UpdateFromEntity(entity); + else if (existing is not null) Items.Remove(existing); + else if (matches) { LoadForList(list); return; } + else return; + } + + // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. + if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) + { + var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); + if (parent is not null) + parent.HasQueuedSubtasks = Items.Any(r => + r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); + } + + Regroup(); + UpdateSubtitle(); } // NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 06b0f55f..b6c35395 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -570,7 +570,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable { var vm = _usageMonitorVmFactory(); vm.ErrorReported += FlashFooterError; - await vm.LoadAsync(); + // Show first, load after: the initial transcript scan takes seconds, and awaiting it + // here left the pill looking unresponsive until the window finally appeared. + vm.BeginLoad(); await Dialogs.ShowUsageMonitorAsync(vm); } finally { _usageMonitorOpen = false; } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index 18bb830b..5d3f38f6 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -19,6 +19,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase public PrimeClaudeTabViewModel Prime { get; } public OnlineInboxSettingsViewModel OnlineInbox { get; } public SessionSkillsSettingsTabViewModel SessionSkills { get; } + + // Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung. + public bool ShowOnlineInbox => false; [ObservableProperty] private string _validationError = ""; [ObservableProperty] private bool _isBusy; @@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase SessionSkills = new SessionSkillsSettingsTabViewModel(worker); } + // Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab — + // carried through load→save verbatim so saving Settings can never reset a dragged value. + private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65); + public async Task LoadAsync() { IsBusy = true; @@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.MaxParallelExecutions = dto.MaxParallelExecutions; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; + _throttleStages = ( + dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct, + dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct); Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; @@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.ModelPresetDtos(), General.UsageGateFiveHourPct, General.UsageGateSevenDayPct, - General.MaxTurnsCeiling); + General.MaxTurnsCeiling, + _throttleStages.FiveSoft, + _throttleStages.FiveHard, + _throttleStages.SevenSoft, + _throttleStages.SevenHard); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs index 3d4d7de9..388d2da9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs @@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase public event Action? ErrorReported; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(GaugeRows))] [NotifyPropertyChangedFor(nameof(IsStale))] [NotifyPropertyChangedFor(nameof(LastError))] [NotifyPropertyChangedFor(nameof(IsGateBlocked))] @@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; - public IReadOnlyList GaugeRows => - Snapshot is null ? Array.Empty() : Snapshot.Limits.Select(BuildGaugeRow).ToList(); + [ObservableProperty] + private IReadOnlyList _gaugeRows = Array.Empty(); public bool IsStale => Snapshot?.IsStale == true; public string? LastError => Snapshot?.LastError; @@ -83,13 +82,30 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase CloseAction?.Invoke(); } + /// + /// Starts the load without blocking the caller, so the host can show the window right away and + /// let it fill in behind the busy spinner. The first load per worker process pays a full scan of + /// ~/.claude/projects (hundreds of MB of transcripts) — awaiting it before showing the + /// window made the usage pill look like it swallowed the click. + /// + public void BeginLoad() => _ = LoadAsync(); + public async Task LoadAsync() { - Snapshot = await _worker.GetUsageSnapshotAsync(); - _worker.UsageUpdatedEvent -= OnUsageUpdated; - _worker.UsageUpdatedEvent += OnUsageUpdated; - ApplyPresetRange(SelectedPresetDays); - await LoadUsageDataAsync(); + IsBusy = true; + try + { + Snapshot = await _worker.GetUsageSnapshotAsync(); + _worker.UsageUpdatedEvent -= OnUsageUpdated; + _worker.UsageUpdatedEvent += OnUsageUpdated; + ApplyPresetRange(SelectedPresetDays); + await LoadUsageDataAsync(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message)); + } + finally { IsBusy = false; } } private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot; @@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase finally { IsBusy = false; } } + partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows(); + + /// + /// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing + /// while the user works the markers doesn't swap the instances out from under the drag. + /// + private void SyncGaugeRows() + { + var limits = Snapshot?.Limits ?? (IReadOnlyList)Array.Empty(); + var existing = GaugeRows.ToDictionary(r => r.Key); + var rows = new List(limits.Count); + + foreach (var limit in limits) + { + var key = GaugeKey(limit); + var bucket = GaugeBucket(limit); + var (soft, hard, gate) = StagesFor(bucket); + var label = BuildGaugeLabel(limit); + + if (existing.TryGetValue(key, out var row)) + { + row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate); + rows.Add(row); + } + else + { + rows.Add(new UsageGaugeRowViewModel( + key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt, + soft, hard, gate, SaveStagesAsync)); + } + } + + if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows; + } + + // Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only. + private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch + { + "session" => "five_hour", + "weekly_all" => "seven_day", + _ => null, + }; + + private static string GaugeKey(UsageLimitDto limit) => + limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind; + + private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch + { + ("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct), + ("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct), + _ => (null, null, null), + }; + + /// + /// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so + /// this never clobbers a field the usage monitor doesn't own. + /// + private async Task SaveStagesAsync(UsageGaugeRowViewModel row) + { + if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate) + return; + + try + { + var settings = await _worker.GetAppSettingsAsync(); + if (settings is null) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline"))); + return; + } + + var updated = row.Bucket == "five_hour" + ? settings with + { + UsageThrottleFiveHourSoftPct = soft, + UsageThrottleFiveHourHardPct = hard, + UsageGateFiveHourPct = gate, + } + : settings with + { + UsageThrottleSevenDaySoftPct = soft, + UsageThrottleSevenDayHardPct = hard, + UsageGateSevenDayPct = gate, + }; + + await _worker.UpdateAppSettingsAsync(updated); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message)); + } + } + private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch { "session" => Loc.T("modals.usageMonitor.gaugeSession"), @@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase _ => limit.Kind, }; - private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit) - { - int? threshold = limit.Kind switch - { - "session" => Snapshot?.FiveHourThresholdPct, - "weekly_all" => Snapshot?.SevenDayThresholdPct, - _ => null, - }; - return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold); - } - private static IReadOnlyList BuildModelDisplayRows(IReadOnlyList rows) { var built = new List(); @@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase } } -public sealed record UsageGaugeRowViewModel( - string Label, - double Percent, - string Severity, - DateTimeOffset? ResetsAt, - int? ThresholdPercent) +/// +/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds +/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar. +/// +public sealed partial class UsageGaugeRowViewModel : ObservableObject { + private readonly Func? _commit; + + public UsageGaugeRowViewModel( + string key, + string? bucket, + string label, + double percent, + string severity, + DateTimeOffset? resetsAt, + int? softPct, + int? hardPct, + int? gatePct, + Func? commit = null) + { + Key = key; + Bucket = bucket; + _label = label; + _percent = percent; + _severity = severity; + _resetsAt = resetsAt; + _softPct = softPct; + _hardPct = hardPct; + _gatePct = gatePct; + _commit = commit; + } + + /// Identity across snapshot updates, so a live poll updates rows instead of replacing them. + public string Key { get; } + + /// Which settings bucket a drag writes to: five_hour, seven_day, or null. + public string? Bucket { get; } + + [ObservableProperty] private string _label; + [ObservableProperty] private double _percent; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsWarnSeverity))] + private string _severity; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ResetText))] + private DateTimeOffset? _resetsAt; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAdjustable))] + private int? _softPct; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAdjustable))] + private int? _hardPct; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAdjustable))] + private int? _gatePct; + + public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null; + public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase); public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : ""; - // Matches the gauge card's inner track width in the view (240 card width - 12*2 padding). - private const double GaugeTrackWidthPx = 216; + /// Live values from a fresh snapshot, without replacing the row instance mid-view. + public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt, + int? softPct, int? hardPct, int? gatePct) + { + Label = label; + Percent = percent; + Severity = severity; + ResetsAt = resetsAt; + SoftPct = softPct; + HardPct = hardPct; + GatePct = gatePct; + } - public double ThresholdMarkerLeftPx => - ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0; + /// Raised by the gauge control when a drag ends — that is when the value is persisted. + [RelayCommand] + private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask; + + // One per legend input box. A typed value goes through the same clamp as a dragged one, so a box + // can't invert the order — and only the edited stage moves, never its neighbours. + [RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft); + [RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard); + [RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate); + + private Task CommitStage(UsageThresholdDrag.Stage stage) + { + if (!IsAdjustable) return Task.CompletedTask; + + var edited = stage switch + { + UsageThresholdDrag.Stage.Soft => SoftPct!.Value, + UsageThresholdDrag.Stage.Hard => HardPct!.Value, + _ => GatePct!.Value, + }; + + var (soft, hard, gate) = UsageThresholdDrag.Apply( + SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited); + SoftPct = soft; + HardPct = hard; + GatePct = gate; + + return Commit(); + } private static string FormatRemaining(DateTimeOffset resetsAt) { @@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel( } } +/// +/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly: +/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour +/// at 0 means "that stage is off" and therefore does not constrain anything. +/// +public static class UsageThresholdDrag +{ + public enum Stage { Soft, Hard, Gate } + + /// Pointer reach for grabbing a marker, as a share of the bar width. + public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent) + { + Stage? best = null; + var bestDistance = double.MaxValue; + + foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) }) + { + var distance = Math.Abs(percent - value); + if (distance > tolerancePercent || distance >= bestDistance) continue; + best = stage; + bestDistance = distance; + } + + return best; + } + + public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent) + { + var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100)); + + return stage switch + { + Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate), + Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate), + Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)), + _ => (soft, hard, gate), + }; + } + + // A neighbour of 0 is switched off and must not pin the dragged marker to 0. + private static int UpperBound(int nearest, int fallback) => + nearest > 0 ? nearest : (fallback > 0 ? fallback : 100); + + // An already-inconsistent stored config (min above max) must not throw mid-drag. + private static int ClampRange(int value, int min, int max) => + max < min ? max : Math.Clamp(value, min, max); +} + public sealed record ModelUsageDisplayRow( string Model, long ClaudeDoInputTokens, diff --git a/src/ClaudeDo.Ui/Views/Controls/UsageGaugeBar.cs b/src/ClaudeDo.Ui/Views/Controls/UsageGaugeBar.cs new file mode 100644 index 00000000..f3deb5f9 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/UsageGaugeBar.cs @@ -0,0 +1,250 @@ +using System; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Rendering; +using ClaudeDo.Ui.ViewModels.Modals; + +namespace ClaudeDo.Ui.Views.Controls; + +/// +/// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate +/// (queue paused). Positions are computed against the control's real width — no hardcoded track +/// size — and the drag math lives in so it stays testable. +/// Values are written back through TwoWay bindings while dragging; +/// fires once on release, which is when the host persists them. +/// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar. +/// +public sealed class UsageGaugeBar : Control, ICustomHitTest +{ + /// How close the pointer has to be to grab a marker. + private const double GrabRadiusPx = 12; + + private const double TrackHeightPx = 10; + private const double MarkerWidthPx = 2; + + public static readonly StyledProperty PercentProperty = + AvaloniaProperty.Register(nameof(Percent)); + + public static readonly StyledProperty IsWarnProperty = + AvaloniaProperty.Register(nameof(IsWarn)); + + public static readonly StyledProperty SoftPctProperty = + AvaloniaProperty.Register( + nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty HardPctProperty = + AvaloniaProperty.Register( + nameof(HardPct), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty GatePctProperty = + AvaloniaProperty.Register( + nameof(GatePct), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty TrackBrushProperty = + AvaloniaProperty.Register(nameof(TrackBrush)); + + public static readonly StyledProperty FillBrushProperty = + AvaloniaProperty.Register(nameof(FillBrush)); + + public static readonly StyledProperty WarnFillBrushProperty = + AvaloniaProperty.Register(nameof(WarnFillBrush)); + + public static readonly StyledProperty SoftMarkerBrushProperty = + AvaloniaProperty.Register(nameof(SoftMarkerBrush)); + + public static readonly StyledProperty HardMarkerBrushProperty = + AvaloniaProperty.Register(nameof(HardMarkerBrush)); + + public static readonly StyledProperty GateMarkerBrushProperty = + AvaloniaProperty.Register(nameof(GateMarkerBrush)); + + public static readonly StyledProperty CommitCommandProperty = + AvaloniaProperty.Register(nameof(CommitCommand)); + + static UsageGaugeBar() + { + AffectsRender( + PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty, + TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty, + SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty); + } + + private UsageThresholdDrag.Stage? _dragging; + + public double Percent + { + get => GetValue(PercentProperty); + set => SetValue(PercentProperty, value); + } + + public bool IsWarn + { + get => GetValue(IsWarnProperty); + set => SetValue(IsWarnProperty, value); + } + + public int? SoftPct + { + get => GetValue(SoftPctProperty); + set => SetValue(SoftPctProperty, value); + } + + public int? HardPct + { + get => GetValue(HardPctProperty); + set => SetValue(HardPctProperty, value); + } + + public int? GatePct + { + get => GetValue(GatePctProperty); + set => SetValue(GatePctProperty, value); + } + + public IBrush? TrackBrush + { + get => GetValue(TrackBrushProperty); + set => SetValue(TrackBrushProperty, value); + } + + public IBrush? FillBrush + { + get => GetValue(FillBrushProperty); + set => SetValue(FillBrushProperty, value); + } + + public IBrush? WarnFillBrush + { + get => GetValue(WarnFillBrushProperty); + set => SetValue(WarnFillBrushProperty, value); + } + + public IBrush? SoftMarkerBrush + { + get => GetValue(SoftMarkerBrushProperty); + set => SetValue(SoftMarkerBrushProperty, value); + } + + public IBrush? HardMarkerBrush + { + get => GetValue(HardMarkerBrushProperty); + set => SetValue(HardMarkerBrushProperty, value); + } + + public IBrush? GateMarkerBrush + { + get => GetValue(GateMarkerBrushProperty); + set => SetValue(GateMarkerBrushProperty, value); + } + + public ICommand? CommitCommand + { + get => GetValue(CommitCommandProperty); + set => SetValue(CommitCommandProperty, value); + } + + private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null; + + // Custom hit test (point is in local coordinates): the control draws itself, so the whole + // rectangle takes the pointer — not just the pixels the track happens to cover. + public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point); + + public override void Render(DrawingContext context) + { + var width = Bounds.Width; + var height = Bounds.Height; + if (width <= 0 || height <= 0) return; + + var top = Math.Max(0, (height - TrackHeightPx) / 2); + var trackHeight = Math.Min(TrackHeightPx, height); + var radius = trackHeight / 2; + + // Transparent full-bounds fill keeps the grab area the whole control, not just the track. + context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height)); + + if (TrackBrush is { } track) + context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius)); + + var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0; + var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush; + if (fillWidth > 0 && fill is not null) + context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius)); + + DrawMarker(context, SoftPct, SoftMarkerBrush, width, height); + DrawMarker(context, HardPct, HardMarkerBrush, width, height); + DrawMarker(context, GatePct, GateMarkerBrush, width, height); + } + + private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height) + { + if (percent is not { } value || brush is null) return; + + var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx)); + context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height)); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!IsAdjustable) return; + + var percent = PercentAt(e.GetPosition(this).X); + _dragging = UsageThresholdDrag.Nearest( + SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent()); + if (_dragging is null) return; + + e.Pointer.Capture(this); + ApplyDrag(_dragging.Value, percent); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (!IsAdjustable) return; + + var percent = PercentAt(e.GetPosition(this).X); + + if (_dragging is { } stage) + { + ApplyDrag(stage, percent); + e.Handled = true; + return; + } + + var hover = UsageThresholdDrag.Nearest( + SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent()); + Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_dragging is null) return; + + _dragging = null; + e.Pointer.Capture(null); + e.Handled = true; + + if (CommitCommand is { } command && command.CanExecute(null)) + command.Execute(null); + } + + private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent) + { + var (soft, hard, gate) = UsageThresholdDrag.Apply( + SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent); + + SoftPct = soft; + HardPct = hard; + GatePct = gate; + } + + private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100); + + private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0; +} diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 55ce88db..d42970ed 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -390,7 +390,8 @@ - + diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs index 5b2a63cb..92110a15 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs @@ -8,6 +8,7 @@ public partial class SettingsModalView : Window public SettingsModalView() { InitializeComponent(); + } protected override void OnDataContextChanged(EventArgs e) diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml index d14d2690..082383dc 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml @@ -18,19 +18,6 @@ - - - - - @@ -82,19 +69,60 @@ + BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270"> - - - - - + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs index 079306bf..11546480 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs @@ -1,8 +1,36 @@ using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.Views.Modals; public partial class UsageMonitorModalView : Window { public UsageMonitorModalView() => InitializeComponent(); + + /// + /// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so + /// the box's Tag names the stage and the row's matching command does the clamp + save. + /// + private void OnStageBoxCommit(object? sender, RoutedEventArgs e) + { + if (sender is not Control { Tag: string stage, DataContext: UsageGaugeRowViewModel row }) return; + + var command = stage switch + { + "soft" => row.CommitSoftCommand, + "hard" => row.CommitHardCommand, + _ => row.CommitGateCommand, + }; + + if (command.CanExecute(null)) command.Execute(null); + } + + private void OnStageBoxKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) return; + OnStageBoxCommit(sender, e); + e.Handled = true; + } } diff --git a/src/ClaudeDo.Ui/Views/WindowDialogService.cs b/src/ClaudeDo.Ui/Views/WindowDialogService.cs index beda84ef..96a826af 100644 --- a/src/ClaudeDo.Ui/Views/WindowDialogService.cs +++ b/src/ClaudeDo.Ui/Views/WindowDialogService.cs @@ -62,7 +62,9 @@ public sealed class WindowDialogService : IDialogService { var dlg = new UsageMonitorModalView { DataContext = vm }; vm.CloseAction = () => dlg.Close(); - await dlg.ShowDialog(_owner); + // The pill sits in both the footer and the Mission Control header, so own the dialog to + // whichever window is active — otherwise it opens behind Mission Control. + await dlg.ShowDialog(ActiveOwner()); } public async Task ShowSettingsAsync(SettingsModalViewModel vm) diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 35fc3ca5..e00d3147 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -48,7 +48,7 @@ subfolder within their area; the namespace stays the area namespace. - **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. -- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md). +- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. **Tool-description style** (not test-enforced) is documented in `External/McpToolDocs.cs`, which also holds the shared boilerplate clauses — read it before adding or editing a tool description. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md). ## Status Model @@ -159,7 +159,6 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag - `TaskMessage` - `WorktreeUpdated` - `TaskUpdated` -- `RunCreated` - `ListUpdated` - `WorkerLog` - `PrimeFired` diff --git a/src/ClaudeDo.Worker/External/AgentMcpTools.cs b/src/ClaudeDo.Worker/External/AgentMcpTools.cs index 80509602..66502d2e 100644 --- a/src/ClaudeDo.Worker/External/AgentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AgentMcpTools.cs @@ -12,7 +12,7 @@ public sealed class AgentMcpTools public AgentMcpTools(AgentFileService agents) => _agents = agents; - [McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")] + [McpServerTool, Description("List available agent definition files (name, description, path) to pick a value for a task's or list's agentPath override.")] public async Task> ListAgents(CancellationToken cancellationToken) => await _agents.ScanAsync(cancellationToken); } diff --git a/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs index b7c8c72f..82f3ab90 100644 --- a/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs @@ -19,7 +19,7 @@ public sealed class AppSettingsMcpTools public AppSettingsMcpTools(IDbContextFactory dbFactory) => _dbFactory = dbFactory; - [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")] + [McpServerTool, Description("Read the worker's global defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy) that apply when a task/list doesn't override them. Read-only.")] public async Task GetAppSettings(CancellationToken cancellationToken) { using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); diff --git a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs index b387f09a..dd1096c7 100644 --- a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs @@ -33,17 +33,15 @@ public sealed class AttachmentMcpTools } [McpServerTool, Description( - "Attach a read-only reference file to a task. These files are handed to the agent at run time, " + - "making them useful to prepare context for a task that will run later (e.g. plans, scripts, specs). " + - "Pass textContent for plain-text files (plans, markdown, scripts). " + - "Pass base64Content only for binary files (images, archives). Exactly one of the two must be provided. " + - "Re-attaching a file with the same fileName overwrites the previous version. " + - "Refuses if the task is currently Running — cancel it first.")] + "Attach a read-only reference file to a task so the agent receives it at run time — use to prepare " + + "context (plans, scripts, specs) for a task that will run later. Exactly one of textContent/" + + "base64Content is required. Re-attaching the same fileName overwrites the previous version." + + McpToolDocs.NotWhileRunning)] public async Task AddTaskAttachment( string taskId, - string fileName, - string? textContent = null, - string? base64Content = null, + [Description("Name to store the attachment under; reusing an existing name overwrites it.")] string fileName, + [Description("Plain-text content (plans, markdown, scripts). Provide this or base64Content, not both.")] string? textContent = null, + [Description("Base64-encoded content for binary files (images, archives). Provide this or textContent, not both.")] string? base64Content = null, CancellationToken ct = default) { var task = await _tasks.GetByIdAsync(taskId, ct) @@ -94,7 +92,8 @@ public sealed class AttachmentMcpTools return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow); } - [McpServerTool, Description("List all attachments on a task (fileName, byteSize, createdAt).")] + [McpServerTool, Description( + "List all attachments on a task — use to check what reference files are already attached before adding more.")] public async Task> ListTaskAttachments( string taskId, CancellationToken ct = default) { @@ -103,8 +102,8 @@ public sealed class AttachmentMcpTools } [McpServerTool, Description( - "Remove a single attachment from a task. Deletes both the file on disk and the database record. " + - "Refuses if the task is currently Running — cancel it first. Returns { removed: true, taskId, fileName } on success.")] + "Remove a single attachment from a task, deleting both the file on disk and its database record." + + McpToolDocs.NotWhileRunning)] public async Task RemoveTaskAttachment( string taskId, string fileName, CancellationToken ct = default) { diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index 7fed1f9f..14ccb54e 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -3,8 +3,14 @@ using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; -public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null); -public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); +public sealed record BatchAddTaskInput( + string Title, + [property: Description("Task description/instructions for the agent.")] string? Description = null, + [property: Description("Model override: haiku|sonnet|opus. Blank inherits the list/global default.")] string? Model = null); +public sealed record BatchSetMyDayInput( + string TaskId, + [property: Description("true to add the task to My Day, false to remove it.")] bool IsMyDay, + [property: Description("Position within My Day; omit to append at the end.")] int? SortOrder = null); // task is populated when found and includeDescription=false (the default, lean reference); // taskFull is populated when found and includeDescription=true (full task incl. @@ -34,14 +40,14 @@ public sealed class BatchMcpTools public BatchMcpTools(ExternalMcpService svc) => _svc = svc; [McpServerTool, Description( - "Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " + - "Returns one result per id: { id, found, task, taskFull, error }. " + - "includeDescription=false (default): found tasks come back in `task` (lean reference, no " + - "Description/Result). includeDescription=true: found tasks come back in `taskFull` (incl. " + - "Description/Result) instead. A missing id is found=false (not an error; task and taskFull both null); " + - "error is only set for an unexpected failure. Max 100 ids.")] + "Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " + + "calling get_task per id. A missing id comes back as found=false, not an error; error is only set " + + "for an unexpected failure." + McpToolDocs.MaxBatch)] public async Task> BatchGetTasks( - string[] taskIds, bool includeDescription = false, CancellationToken cancellationToken = default) + string[] taskIds, + [Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " + + "(default), return a lean reference in `task`.")] bool includeDescription = false, + CancellationToken cancellationToken = default) { EnsureWithinCap(taskIds, nameof(taskIds)); @@ -75,19 +81,15 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Create many tasks in one list at once. Each item: { title, description?, model? } " + - "(model: haiku|sonnet|opus, blank = inherit list/global default). " + - "queueImmediately enqueues every created task. " + - "Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " + - "a lean reference (id, listId, title, status, sortOrder, isMyDay), not the description you just " + - "sent. Each item is always created — possibleDuplicates is a non-blocking heads-up (up to 3 open " + - "tasks in the same list with a strongly overlapping title, id/title/status only); check it and " + - "mention any hit to the caller, but do not treat it as an error. Max 100 items.")] + "Create many tasks in one list at once — use instead of repeated add_task calls when seeding a list. " + + "Every item is still created even if it looks like a duplicate; possibleDuplicates is a non-blocking " + + "heads-up (up to 3 similar open tasks in the list) worth mentioning to the caller, not an error." + + McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch)] public async Task> BatchAddTasks( string listId, BatchAddTaskInput[] tasks, string? createdBy = null, - bool queueImmediately = false, + [Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false, CancellationToken cancellationToken = default) { EnsureWithinCap(tasks, nameof(tasks)); @@ -113,11 +115,13 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " + - "same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " + - "Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + "Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " + + "update_task_status per task. 'Done' is refused per-item for a task with an active worktree." + + McpToolDocs.MaxBatch)] public async Task> BatchUpdateTaskStatus( - string[] taskIds, string status, CancellationToken cancellationToken) + string[] taskIds, + [Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status, + CancellationToken cancellationToken) { EnsureWithinCap(taskIds, nameof(taskIds)); return await RunPerTaskAsync(taskIds, @@ -125,9 +129,8 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Cancel many running tasks at once. Returns one result per id: " + - "{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " + - "Max 100 ids.")] + "Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " + + "ok=true with cancelled=false just means the task wasn't running." + McpToolDocs.MaxBatch)] public async Task> BatchCancelTasks( string[] taskIds, CancellationToken cancellationToken) { @@ -151,8 +154,8 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Delete many tasks at once. A Running task is refused (cancel it first) and reported " + - "as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + "Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." + + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)] public async Task> BatchDeleteTasks( string[] taskIds, CancellationToken cancellationToken) { @@ -162,9 +165,9 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " + - "Still cap-guarded — items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + - "(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")] + "Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " + + "Still cap-guarded: items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + + "(ok=false) without blocking the rest." + McpToolDocs.MaxBatch)] public async Task> BatchSetMyDay( BatchSetMyDayInput[] items, CancellationToken cancellationToken) { @@ -188,12 +191,13 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Remove the worktrees of many tasks at once (directory + git branch). " + - "force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " + - "even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " + - "Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")] + "Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " + + "of calling cleanup_task_worktree per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)] public async Task> BatchCleanupTaskWorktrees( - string[] taskIds, bool force = false, CancellationToken cancellationToken = default) + string[] taskIds, + [Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " + + "is still refused either way.")] bool force = false, + CancellationToken cancellationToken = default) { EnsureWithinCap(taskIds, nameof(taskIds)); diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 5556d26c..383a13a1 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -46,7 +46,7 @@ public sealed class ConfigMcpTools _dbFactory = dbFactory; } - [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns { found: false, config: null } if no config is set.")] + [McpServerTool, Description("Read a list's default run config — the fallback used by tasks in this list that don't set their own overrides. Returns { found: false, config: null } if none is set.")] public async Task GetListConfig(string listId, CancellationToken cancellationToken) { var cfg = await _lists.GetConfigAsync(listId, cancellationToken); @@ -56,9 +56,8 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list " + - "config. Returns { ok, listId, config } — config is null when the config was cleared, otherwise it echoes " + - "the fields that were set (a field is null there if it was individually left unset/cleared).")] + "Set a list's default model/system prompt/agent path/max turns — the fallback for tasks in this list " + + "that don't override them. Passing all four as null clears the list config instead of setting one.")] public async Task SetListConfig( string listId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) @@ -90,9 +89,8 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to " + - "clear that override. Returns { ok, taskId, config } — config echoes the resulting overrides (a field is " + - "null there if it was cleared or never set).")] + "Set per-task overrides for model/system prompt/agent path/max turns; these take precedence over the " + + "list's default config for this one task. Pass null for any field to clear that override.")] public async Task SetTaskConfig( string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) @@ -109,7 +107,7 @@ public sealed class ConfigMcpTools return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, maxTurns)); } - [McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns { found: false, config: null } if no override is set on this task.")] + [McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")] public async Task GetTaskConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -120,11 +118,11 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Get the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent path, " + - "whether a system prompt is set, and skill names — with each field's source (task/list/preset/global). " + - "Uses the exact same resolution TaskRunner runs with, so this never drifts from get_app_settings/" + - "get_task_config's raw, possibly-unused values. maxTurns also reports the raw requested value and " + - "whether it was clamped to the global ceiling. Read-only, no side effects.")] + "Report the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent " + + "path, whether a system prompt is set, and skill names — each tagged with its source " + + "(task/list/preset/global). Use this over get_task_config/get_app_settings when you need resolved " + + "values, not raw overrides. maxTurns also reports the raw requested value and whether it was clamped " + + "to the global ceiling.")] public async Task GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 00637b00..23fc6319 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -26,7 +26,6 @@ public sealed record CancelTaskResult(bool Cancelled, string Id); // review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less // child) contributed nothing, so a reviewer sees them before approving instead of after. public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList? EmptyChildren = null); -public sealed record StatusValueDto(string Status, string Meaning); public sealed record RunTaskNowResult(bool Started, string TaskId); public sealed record TaskDto( @@ -161,7 +160,8 @@ public sealed class ExternalMcpService _planningMerge = planningMerge; } - [McpServerTool, Description("List all task lists available in ClaudeDo.")] + [McpServerTool, Description( + "List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")] public async Task> ListTaskLists(CancellationToken cancellationToken) { var lists = await _lists.GetAllAsync(cancellationToken); @@ -169,17 +169,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " + - "Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled. " + - "includeDescription=false (default): returns lean task references in `tasks` (no Description/Result) — " + - "use this unless you actually need the description text, since a list of verbosely-described tasks can " + - "otherwise blow past the response size limit. " + - "includeDescription=true: returns full tasks (incl. Description/Result) in `tasksFull` instead; `tasks` is " + - "null in that case.")] + "List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status.")] public async Task ListTasks( string listId, + [Description("Only return tasks with this CreatedBy value.")] string? createdBy = null, + [Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " + + "WaitingForChildren, Done, Failed or Cancelled.")] string? status = null, + [Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " + + "need the description text, since verbosely-described tasks can blow past the response size " + + "limit. true: full tasks in `tasksFull` instead (`tasks` is then null).")] bool includeDescription = false, CancellationToken cancellationToken = default) { @@ -206,10 +206,12 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Get a single task by id, including its current status and result. " + - "Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + - "A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " + - "Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")] + "Get a single task by id, including its current status and result — the canonical reference for what a " + + "status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + + "A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " + + "Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " + + "for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " + + "is done but whose children are still running.")] public async Task GetTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -227,21 +229,19 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " + - "Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " + - "'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " + - "Leave model null to inherit the list/global default. " + - "Returns { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " + - "sortOrder, isMyDay) — not the description you just sent. The task is always created — " + - "possibleDuplicates is a non-blocking heads-up (up to 3 open tasks in the same list with a " + - "strongly overlapping title, id/title/status only); check it and mention any hit to the caller, " + - "but do not treat it as an error.")] + "Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " + + "heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " + + "any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef)] public async Task AddTask( string listId, string title, string? description = null, string? createdBy = null, + [Description("true: enqueue the task for agent execution right away.")] bool queueImmediately = false, + [Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " + + "for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " + + "list/global default (normally sonnet).")] string? model = null, CancellationToken cancellationToken = default) { @@ -354,9 +354,8 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. " + - "Refuses if the task is currently Running. Returns a lean task reference (id, listId, title, status, " + - "sortOrder, isMyDay) — not the description you just sent.")] + "Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." + + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task UpdateTask( string taskId, string? title = null, @@ -380,12 +379,12 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Append a subtask (step) to a task. orderNum defaults to the end. " + - "Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " + + "task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task AddSubtask( string taskId, string title, + [Description("Position among the existing steps; defaults to the end.")] int? orderNum = null, CancellationToken cancellationToken = default) { @@ -419,16 +418,14 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " + - "use run_task_now for execution control, and review_task to act on a WaitingForReview task. " + - "Settable: Idle (reset to editable), Queued (enqueue for execution), " + - "Cancelled (retire the task without deleting it; it can be reset to Idle later), " + - "Done (mark complete; refused if the task has an active worktree — use review_task to approve " + - "and merge that worktree instead). " + - "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " + + "review_task to act on a WaitingForReview task — neither is reachable from here." + McpToolDocs.LeanTaskRef)] public async Task UpdateTaskStatus( string taskId, + [Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " + + "deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " + + "active worktree — use review_task to approve and merge that worktree instead). No other " + + "value is settable externally.")] string status, CancellationToken cancellationToken) { @@ -482,27 +479,29 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Review a task that is WaitingForReview. " + - "decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " + - "targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " + - "(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " + - "mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " + - "aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " + - "markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " + - "or abort_merge to cancel. " + - "decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " + - "decision='reject_park' → Idle for manual editing (feedback ignored). " + - "decision='cancel' → Cancelled. " + - "Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued). " + - "The result's task field is a lean reference (id, listId, title, status, sortOrder, isMyDay), not the task's description. " + - "emptyChildren (parent approve only) lists the Done children about to be unit-merged whose own review range " + - "contributed nothing (e.g. a child that reported CLAUDEDO_BLOCKED and committed no code) — check it before " + - "trusting that every child actually delivered something.")] + "Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " + + "'approve' is review+merge, exactly like the UI's Approve: a childless task merges its worktree into " + + "targetBranch then goes Done; a task with children drives the unit merge (parent worktree if active + each " + + "Done child in order); a task without an active worktree approves straight to Done. Fails if the task is " + + "not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " + + "means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " + + "the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " + + "reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " + + "delivered something." + McpToolDocs.LeanTaskRef)] public async Task ReviewTask( string taskId, + [Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")] string decision, + [Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " + + "this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " + + "just returns the task to Idle for manual editing.")] string? feedback = null, + [Description("Branch an approve merges into; defaults to the repo's current branch.")] string? targetBranch = null, + [Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " + + "no half-merged state, and you resolve in the ClaudeDo UI. true: leave the conflict markers " + + "in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " + + "or abort_merge to cancel.")] bool leaveConflictsInTree = false, CancellationToken cancellationToken = default) { @@ -634,7 +633,10 @@ public sealed class ExternalMcpService } } - [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")] + [McpServerTool, Description( + "Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" + + "occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " + + "enqueue via update_task_status instead of retrying in a loop.")] public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) { try @@ -653,7 +655,8 @@ public sealed class ExternalMcpService return new RunTaskNowResult(true, taskId); } - [McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")] + [McpServerTool, Description( + "Cancel a running task, killing its agent process. cancelled=false means the task was not running.")] public async Task CancelTask(string taskId, CancellationToken cancellationToken) { var cancelled = _queue.CancelTask(taskId); @@ -661,7 +664,9 @@ public sealed class ExternalMcpService return new CancelTaskResult(cancelled, taskId); } - [McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")] + [McpServerTool, Description( + "Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." + + McpToolDocs.NotWhileRunning)] public async Task DeleteTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -676,31 +681,12 @@ public sealed class ExternalMcpService return new DeleteTaskResult(true, taskId); } - // ── Status reference ───────────────────────────────────────────────────── - - [McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")] - public Task> GetTaskStatusValues() => - Task.FromResult>([ - new("Idle", "Not yet queued; task is editable and will not run until enqueued."), - new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."), - new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."), - new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."), - new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."), - new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."), - new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."), - new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."), - ]); - // ── Worktree / git tools ────────────────────────────────────────────────── [McpServerTool, Description( - "Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " + - "baseCommit (SHA where the branch was created), ahead (commits on branch since base), " + - "behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " + - "isDirty (has uncommitted changes in the worktree directory), " + - "mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " + - "if it has been merged and that succeeded after this field was introduced; null otherwise — " + - "required by revert_merge). " + + "Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " + + "mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " + + "read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " + "Throws if the task or its worktree does not exist.")] public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) { @@ -718,16 +704,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Get the diff for a task's worktree relative to its base commit. For a worktree-less " + - "list-handler host task (Mission Control's \"Let Claude handle it\"), returns the fixed " + - "HandlerBaseCommit..HandlerHeadCommit range over the list's working dir instead. " + - "stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " + - "stat=true: returns a --stat summary (changed files with insertion/deletion counts). " + - "files always lists the changed file paths regardless of stat mode. " + - "totalBytes is the uncapped diff size (useful when truncated=true). " + - "Throws if the task has no worktree/review range, or the relevant directory is missing from disk.")] + "Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " + + "list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " + + "instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " + + "totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " + + "directory is missing from disk.")] public async Task GetTaskDiff( - string taskId, bool stat = false, CancellationToken cancellationToken = default) + string taskId, + [Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " + + "per-file insertion/deletion counts — start here when the diff may be large.")] + bool stat = false, + CancellationToken cancellationToken = default) { var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken); @@ -782,21 +769,21 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Merge a task's worktree branch into targetBranch (default: main). " + - "noFf=true (default): always creates a merge commit (--no-ff). " + - "dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " + - "allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " + - "On success: merged=true, mergeCommit contains the new merge commit SHA. " + - "On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " + - "leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " + - "tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " + - "or abort_merge to cancel.")] + "Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " + + "review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " + + "merged=false and conflicts lists the affected files.")] public async Task MergeTask( string taskId, string targetBranch = "main", + [Description("true (default): always create a merge commit (--no-ff).")] bool noFf = true, + [Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")] bool dryRun = false, + [Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")] bool allowWaitingForReview = false, + [Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " + + "leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " + + "resolve them there and call continue_merge, or abort_merge to cancel.")] bool leaveConflictsInTree = false, CancellationToken cancellationToken = default) { @@ -848,11 +835,9 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " + - "merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " + - "merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " + - "post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " + - "lists the affected files — resolve them and call continue_merge again. " + + "Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " + + "(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " + + "merged=false with conflicts listed means markers are still present — resolve them and call again. " + "Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")] public async Task ContinueMerge(string taskId, CancellationToken cancellationToken) { @@ -920,10 +905,8 @@ public sealed class ExternalMcpService [McpServerTool, Description( "Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " + - "Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " + - "unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " + - "Throws if there is no in-progress merge for the task. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " + + "(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)] public async Task AbortMerge(string taskId, CancellationToken cancellationToken) { _ = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -945,21 +928,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Non-destructive merge preview for a task's worktree branch against targetBranch (default: the repo's " + - "current branch), via `git merge-tree --write-tree` — does NOT touch the working tree, index, or HEAD. " + - "status: 'clean' (mergeable; changedFileCount is the size of that merge) or 'conflict' (conflictFiles " + - "lists the paths git would stop on). behind = commits on targetBranch not yet on this task's branch, so " + - "you can spot a stale branch even when the preview itself is clean. " + - "IMPORTANT: a clean preview says nothing about whether the merged result compiles or passes tests — git " + - "can merge two changes cleanly (e.g. one file deletes a symbol another file still references) and still " + - "break the build. " + - "isEmpty=true means the task's review range contributed nothing (no commits ahead of base, or — for a " + - "worktree-less list-handler host task — HandlerBaseCommit == HandlerHeadCommit); do not mistake a small " + - "changedFileCount for an empty one, check isEmpty instead. " + - "Throws a clear error if the task has neither an active worktree nor a handler commit range, or the " + - "list's working directory is missing from disk.")] + "Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " + + "working tree, index and HEAD are untouched. status is 'clean' or 'conflict' (conflictFiles lists where git " + + "would stop); behind counts commits on targetBranch not yet on this branch, which flags a stale branch even " + + "when the preview is clean. IMPORTANT: a clean preview says nothing about whether the result compiles or " + + "passes tests — git can merge two changes cleanly (one file deleting a symbol another still references) and " + + "still break the build. isEmpty=true means the task's review range contributed nothing; check that flag " + + "rather than reading a small changedFileCount as empty. Throws if the task has neither an active worktree " + + "nor a handler commit range, or the list's working directory is missing from disk.")] public async Task PreviewMerge( string taskId, + [Description("Branch to preview against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default) { @@ -968,18 +947,16 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Merge preview plus file-overlap check across several tasks at once, all previewed against the same " + - "targetBranch (default: the repo's current branch). For each taskId returns the same fields as " + - "preview_merge (status/conflictFiles/changedFileCount/behind; error is set instead if that task could not " + - "be previewed, and it is then excluded from the overlap computation). overlaps lists, for each file " + - "touched by MORE THAN ONE of the given tasks (via each task's own diff, not the merge preview itself), " + - "which tasks touch it — passing a single taskId always yields an empty overlaps list. " + - "IMPORTANT: file-name overlap is a HINT, not a guarantee of a real collision, and its absence is not a " + - "guarantee of safety — two tasks touching different files entirely (e.g. one deletes a symbol, another " + - "still references it elsewhere) can still collide, and this tool will not flag that case. " + - "isEmpty=true (per entry) means that task's review range contributed nothing — see preview_merge.")] + "Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " + + "check between them. Per entry you get preview_merge's fields, or error instead when that task could not " + + "be previewed (it is then left out of the overlap computation). overlaps names, for each file touched by " + + "MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no overlaps. " + + "IMPORTANT: overlap is a HINT and its absence is not safety — two tasks touching entirely different files " + + "(one deleting a symbol, another still referencing it) can still collide unflagged, and as with " + + "preview_merge a clean result does not mean the merge builds.")] public async Task PreviewMergeSet( IReadOnlyList taskIds, + [Description("Branch to preview every task against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default) { @@ -1076,18 +1053,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " + - "a new commit, never a reset/rewrite (the target working directory is shared with other sessions). " + - "Requires the task to be Done with a Merged worktree that has a recorded merge commit; tasks merged " + - "before this feature existed have no recorded commit and are refused rather than guessed via git log. " + - "On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " + - "WaitingForReview so it can be reconsidered. " + - "On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " + - "left in the tree) and conflicts lists the files that would have conflicted. " + - "Throws if there is no recorded merge commit, the repo is mid-merge/mid-revert, or the target working " + - "tree has uncommitted changes from another session.")] + "Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " + + "reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " + + "Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " + + "first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " + + "is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " + + "repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")] public async Task RevertMerge( - string taskId, string targetBranch = "main", CancellationToken cancellationToken = default) + string taskId, + [Description("Branch carrying the merge commit; defaults to main.")] + string targetBranch = "main", + CancellationToken cancellationToken = default) { var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken); @@ -1104,10 +1080,8 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "List all ClaudeDo-tracked worktrees. " + - "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " + - "isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " + - "Only worktrees recorded in the ClaudeDo database are returned.")] + "Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " + + "the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk.")] public async Task> ListWorktrees(CancellationToken cancellationToken) { var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); @@ -1125,12 +1099,14 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Remove a task's worktree directory and delete its git branch. " + - "force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " + - "force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " + - "Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")] + "Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " + + "went too." + McpToolDocs.NotWhileRunning)] public async Task CleanupTaskWorktree( - string taskId, bool force = false, CancellationToken cancellationToken = default) + string taskId, + [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " + + "those changes.")] + bool force = false, + CancellationToken cancellationToken = default) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken) @@ -1155,10 +1131,9 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Send a follow-up prompt to an existing Claude session (multi-turn continuation). " + - "The agent resumes using --resume with the session ID from the task's last run. " + - "Runs in the override execution slot; throws if the slot is busy — try again later. " + - "Returns a status string from the execution slot.")] + "Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " + + "resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " + + "same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")] public async Task ContinueTask( string taskId, string followUpPrompt, @@ -1187,10 +1162,10 @@ public sealed class ExternalMcpService // ── Daily prep ─────────────────────────────────────────────────────────── [McpServerTool, Description( - "Daily prep: returns the open tasks eligible for today's MyDay selection. " + - "candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " + - "currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " + - "maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")] + "Daily prep: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " + + "non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " + + "flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " + + "never exceed maxTasks.")] public async Task GetDailyPrepCandidates(CancellationToken cancellationToken) { await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); @@ -1225,14 +1200,13 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " + - "(use consecutive sortOrder values to keep related tasks together). " + - "Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " + - "clearing (isMyDay=false) is always allowed. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Daily prep: set or clear a task's MyDay flag. Setting it is rejected once the MyDay cap " + + "(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." + + McpToolDocs.LeanTaskRef)] public async Task SetMyDay( string taskId, bool isMyDay, + [Description("Position in the MyDay list; use consecutive values to keep related tasks together.")] int? sortOrder = null, CancellationToken cancellationToken = default) { diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index 6866d7c4..b44d05b7 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -20,13 +20,15 @@ public sealed class HandoffMcpTools } [McpServerTool, Description( - "End of Phase 2 for the list handler (\"Let Claude handle it\"): hand this run off to a fresh " + - "ConPTY session that carries out Phases 3-5, without dragging along this session's dedupe/rewrite " + - "context. taskId is this session's own handler task id; survivingTaskIds are the tasks that made " + - "it past dedupe, in the order to run them. Reuses the SAME handler task -- no new task is created, " + - "and HandlerBaseCommit is untouched. The current tile stays open; end your own turn after calling this.")] + "Call at the end of Phase 2 of the list handler (\"Let Claude handle it\") to hand this run off " + + "to a fresh ConPTY session that carries out Phases 3-5, without dragging along this session's " + + "dedupe/rewrite context. Reuses the SAME handler task -- no new task is created, and " + + "HandlerBaseCommit is untouched. The current tile stays open; you must end your own turn " + + "immediately after calling this.")] public async Task HandoffListHandler( - string taskId, IReadOnlyList survivingTaskIds, CancellationToken cancellationToken) + [Description("This session's own handler task id.")] string taskId, + [Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList survivingTaskIds, + CancellationToken cancellationToken) { if (survivingTaskIds.Count == 0) throw new InvalidOperationException("survivingTaskIds must contain at least one task id."); diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index aa1ecb7a..3980f51f 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -20,7 +20,7 @@ public sealed class LifecycleMcpTools _reset = reset; } - [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted. Returns { reset: true, taskId } on success.")] + [McpServerTool, Description("Reset a failed task back to Idle so it can be run again, discarding its now-stale worktree. Only tasks with Status=Failed are accepted; other statuses throw.")] public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/ListMcpTools.cs b/src/ClaudeDo.Worker/External/ListMcpTools.cs index 72fe5f2f..5ac26da7 100644 --- a/src/ClaudeDo.Worker/External/ListMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ListMcpTools.cs @@ -21,9 +21,14 @@ public sealed class ListMcpTools _broadcaster = broadcaster; } - [McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")] + [McpServerTool, Description("Create a new task list — the top-level grouping tasks belong to, with its own working dir, commit type, and default run config.")] public async Task CreateList( - string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default) + string name, + [Description("Absolute local path to an existing git repository this list's tasks will run against. Not validated here — the first task run fails if the path isn't an actual git repo. Omit to run this list's tasks in a throwaway sandbox with no worktree.")] + string? workingDir = null, + [Description("Conventional-commit-style type prefix for this list's task commits (e.g. 'feat', 'fix'). Defaults to 'chore'.")] + string? commitType = null, + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException("name is required."); @@ -41,9 +46,14 @@ public sealed class ListMcpTools return ToDto(entity); } - [McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")] + [McpServerTool, Description("Rename a list, or change its working dir / default commit type without recreating it. Pass null for any field to leave it unchanged.")] public async Task UpdateList( - string listId, string? name = null, string? workingDir = null, string? commitType = null, + string listId, + string? name = null, + [Description("Absolute local path to an existing git repository this list's tasks will run against; not validated until the next task runs. Null leaves it unchanged; pass an empty string to clear it and switch this list to sandbox-only task runs.")] + string? workingDir = null, + [Description("New default commit type prefix for this list's task commits. Null leaves it unchanged.")] + string? commitType = null, CancellationToken cancellationToken = default) { var entity = await _lists.GetByIdAsync(listId, cancellationToken) @@ -62,7 +72,7 @@ public sealed class ListMcpTools return ToDto(entity); } - [McpServerTool, Description("Delete a list and its tasks. Irreversible. Returns { deleted: true, id } on success.")] + [McpServerTool, Description("Permanently delete a list and all its tasks — no undo. Only for removing the whole list, not a single task within it.")] public async Task DeleteList(string listId, CancellationToken cancellationToken) { _ = await _lists.GetByIdAsync(listId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/McpToolDocs.cs b/src/ClaudeDo.Worker/External/McpToolDocs.cs new file mode 100644 index 00000000..46b814af --- /dev/null +++ b/src/ClaudeDo.Worker/External/McpToolDocs.cs @@ -0,0 +1,28 @@ +namespace ClaudeDo.Worker.External; + +/// +/// Boilerplate clauses shared by several external MCP tool descriptions. Every tool description is +/// still emitted in full to the client — these constants only stop the wording from drifting apart +/// across ~50 attributes. +/// +/// Description style (keep new tools in line with it): +/// 1. First sentence says what the tool does AND when to reach for it — MCP clients rank tools by +/// this text, so the trigger must not be buried behind return-shape prose. +/// 2. Then only non-obvious preconditions and refusals. +/// 3. Document parameters with [Description] on the parameter, not in the tool description. +/// 4. Describe result fields only where the caller must branch on them (isEmpty, truncated, +/// conflicts, …). Everything else is visible in the first actual response. +/// 5. No design rationale or "since this feature was introduced" history. +/// Budget: ~400 chars for a simple tool, ~800 for the merge/review family. +/// +internal static class McpToolDocs +{ + /// Warns that the payload is the lean reference, not the task's description/result. + public const string LeanTaskRef = " Returns a lean task reference, not the task's description."; + + /// Batch-size cap shared by every BatchMcpTools entry point. + public const string MaxBatch = " Max 100 per call."; + + /// Mutations that refuse to touch a task while its agent is running. + public const string NotWhileRunning = " Refused while the task is Running — cancel it first."; +} diff --git a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs index fc1e9763..d1d65ca4 100644 --- a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs +++ b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs @@ -28,15 +28,12 @@ public sealed class QueueStateMcpTools } [McpServerTool, Description( - "Read-only snapshot of the execution queue -- observe slot occupancy instead of inferring " + - "it from maxParallelExecutions. Result: { configuredSlots, effectiveSlots, activeSlots: " + - "[{ slot, taskId, startedAt }], waitingTaskIds }. configuredSlots is Settings -> " + - "MaxParallelExecutions; effectiveSlots is that value stepped down by the usage throttle " + - "(lower when the 5h/7d usage window is filling up) -- compare the two to see whether " + - "throttling is currently active. activeSlots lists every task presently holding an " + - "execution slot, with slot \"queue\" for a normal queue slot or \"override\" for the single " + - "run_task_now/continue_task slot. waitingTaskIds lists queued, unblocked, non-manual, due " + - "tasks in the order the queue would pick them next.")] + "Read-only snapshot of the execution queue -- call this to observe slot occupancy instead " + + "of inferring it from maxParallelExecutions. effectiveSlots is configuredSlots stepped down " + + "by the usage throttle (lower when the 5h/7d usage window fills up), so comparing the two " + + "shows whether throttling is currently active. Each active slot is \"queue\" (a normal " + + "queue slot) or \"override\" (the single run_task_now/continue_task slot). waitingTaskIds " + + "lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next.")] public async Task GetQueueState(CancellationToken cancellationToken = default) { var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken); diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs index 06e4d799..d334fc7a 100644 --- a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -24,14 +24,17 @@ public sealed class RunHistoryMcpTools public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs; - [McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")] + [McpServerTool, Description( + "List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " + + "oldest to newest by run number, so the last entry is the most recent. Use a run's id from here with " + + "get_run to fetch it individually.")] public async Task> ListRuns(string taskId, CancellationToken cancellationToken) { var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken); return runs.Select(ToDto).ToList(); } - [McpServerTool, Description("Get a single execution run by its run id.")] + [McpServerTool, Description("Get one execution run's full detail by its run id, obtained from list_runs.")] public async Task GetRun(string runId, CancellationToken cancellationToken) { var run = await _runs.GetByIdAsync(runId, cancellationToken) @@ -40,18 +43,16 @@ public sealed class RunHistoryMcpTools } [McpServerTool, Description( - "Fetch log entries from a task's latest run. " + - "Returns { available, entries, totalLines, truncated }. " + - "available=false means no log exists yet (task is queued or just started — not an error). " + - "entries are the individual lines (NDJSON messages) from Claude's streaming output. " + - "Default: returns the last 50 entries (tail=50). " + - "tail: override the number of trailing entries to return. " + - "offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " + - "truncated=true when fewer entries are returned than totalLines.")] + "Fetch NDJSON log lines from a task's latest run — use this to check progress or debug a task without " + + "opening the log file. Defaults to the last 50 lines. available=false means no log exists yet (queued " + + "or just started — not an error); truncated=true when fewer entries are returned than totalLines.")] public async Task GetTaskLog( string taskId, + [Description("Number of trailing entries to return; ignored if offset or limit is set. Default 50.")] int? tail = null, + [Description("0-based entry index to start from; overrides tail when set. Combine with limit to page through the log.")] int? offset = null, + [Description("Max entries to return starting at offset. Omit to return everything from offset to the end.")] int? limit = null, CancellationToken cancellationToken = default) { diff --git a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs index cede7a45..850a05fb 100644 --- a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs +++ b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs @@ -29,19 +29,24 @@ public sealed class TaskWaitMcpTools } [McpServerTool, Description( - "Blocks until at least one of the given tasks leaves Queued/Running, or until timeoutSeconds elapses " + - "(clamped server-side to 900s). Returns immediately if any task is already outside Queued/Running " + - "when called (an unknown id is reported as status \"NotFound\" and counts as changed). Use this instead " + - "of polling get_task in a loop. Pitfall: a planning parent with children goes Running -> " + - "WaitingForChildren while its children are still working, and by default that counts as \"changed\" -- " + - "so waiting on a parent returns immediately even though the work isn't done. Set " + - "treatWaitingForChildrenAsBusy=true to keep waiting through WaitingForChildren; the call then only " + - "returns once the parent reaches WaitingForReview or a terminal status (default: false, unchanged " + - "legacy behavior). Requires the calling claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for " + - "a long wait to actually be held open -- ClaudeDo's own launchers already set this. " + - "Result: { changed: [{ taskId, status }], timedOut }.")] + "Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " + + "polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " + + "(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " + + "goes Running -> WaitingForChildren while its children are still working, so by default " + + "waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Requires the calling " + + "claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " + + "held open -- ClaudeDo's own launchers already set this.")] public async Task WaitForTaskChange( - string[] taskIds, int timeoutSeconds = 60, bool treatWaitingForChildrenAsBusy = false, + string[] taskIds, + [Description( + "How long to wait, in seconds, before giving up. Clamped server-side to 900s (15 min) " + + "regardless of what's passed.")] + int timeoutSeconds = 60, + [Description( + "When true, WaitingForChildren still counts as busy, so waiting on a planning parent " + + "continues until it reaches WaitingForReview or a terminal status instead of returning " + + "as soon as it leaves Running.")] + bool treatWaitingForChildrenAsBusy = false, CancellationToken cancellationToken = default) { if (taskIds.Length == 0) diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index f46dabdf..c3828c2d 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -40,9 +40,6 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task ListUpdated(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId); - public Task RunCreated(string taskId, int runNumber, bool isRetry) => - _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry); - public Task UsageUpdated(UsageSnapshotDto snapshot) => _hub.Clients.All.SendAsync("UsageUpdated", snapshot); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 1fc3f6bf..fccea1a1 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -50,7 +50,12 @@ public record AppSettingsDto( List? ModelPresets = null, int UsageGateFiveHourPct = 80, int UsageGateSevenDayPct = 90, - int MaxTurnsCeiling = 80); + int MaxTurnsCeiling = 80, + // Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings. + int UsageThrottleFiveHourSoftPct = 50, + int UsageThrottleFiveHourHardPct = 65, + int UsageThrottleSevenDaySoftPct = 50, + int UsageThrottleSevenDayHardPct = 65); // Per-model run defaults (effort + turn budget) edited in Settings -> General. public record ModelPresetDto(string Model, string Effort, int MaxTurns); @@ -137,7 +142,12 @@ public record UsageSnapshotDto( string? LastError, int ConfiguredSlots, int EffectiveSlots, - string? ThrottleBucket); + string? ThrottleBucket, + // Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge. + int ThrottleFiveHourSoftPct, + int ThrottleFiveHourHardPct, + int ThrottleSevenDaySoftPct, + int ThrottleSevenDayHardPct); public record ModelUsageRowDto( DateOnly Date, @@ -446,7 +456,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(), row.UsageGateFiveHourPct, row.UsageGateSevenDayPct, - row.MaxTurnsCeiling); + row.MaxTurnsCeiling, + row.UsageThrottleFiveHourSoftPct, + row.UsageThrottleFiveHourHardPct, + row.UsageThrottleSevenDaySoftPct, + row.UsageThrottleSevenDayHardPct); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub UsageGateFiveHourPct = dto.UsageGateFiveHourPct, UsageGateSevenDayPct = dto.UsageGateSevenDayPct, MaxTurnsCeiling = dto.MaxTurnsCeiling, + UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct, + UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct, + UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct, + UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct, }); } diff --git a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs index 3afb1e58..99e5c665 100644 --- a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs +++ b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs @@ -1,6 +1,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Online.Interfaces; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; @@ -15,19 +16,22 @@ public sealed class OnlineSyncService : BackgroundService private readonly IOnlineAuthProvider _auth; private readonly OnlineInboxConfig _config; private readonly ILogger _logger; + private readonly HubBroadcaster _broadcaster; public OnlineSyncService( IDbContextFactory dbFactory, IOnlineInboxApi api, IOnlineAuthProvider auth, OnlineInboxConfig config, - ILogger logger) + ILogger logger, + HubBroadcaster broadcaster) { _dbFactory = dbFactory; _api = api; _auth = auth; _config = config; _logger = logger; + _broadcaster = broadcaster; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -129,6 +133,8 @@ public sealed class OnlineSyncService : BackgroundService CommitType = CommitTypeRegistry.DefaultType, }; await tasks.AddAsync(entity, ct); + // Without this the imported task only shows up after a manual reload. + await _broadcaster.TaskUpdated(entity.Id); await _api.MarkImportedAsync(remote.Id, ct); _logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title); diff --git a/src/ClaudeDo.Worker/Queue/QueueService.cs b/src/ClaudeDo.Worker/Queue/QueueService.cs index b01a6ec9..cfbfec01 100644 --- a/src/ClaudeDo.Worker/Queue/QueueService.cs +++ b/src/ClaudeDo.Worker/Queue/QueueService.cs @@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct) { int configured; - int softPct, hardPct, gateFivePct, gateSevenPct; + UsageThresholds fiveHour, sevenDay; try { using var context = _dbFactory.CreateDbContext(); var settings = await new AppSettingsRepository(context).GetAsync(ct); configured = Math.Max(1, settings.MaxParallelExecutions); - softPct = settings.UsageThrottleSoftPct; - hardPct = settings.UsageThrottleHardPct; - gateFivePct = settings.UsageGateFiveHourPct; - gateSevenPct = settings.UsageGateSevenDayPct; + fiveHour = new UsageThresholds( + settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, + settings.UsageGateFiveHourPct); + sevenDay = new UsageThresholds( + settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, + settings.UsageGateSevenDayPct); } catch (Exception ex) { @@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService } var effective = UsageThrottle.EffectiveSlots( - configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, - softPct, hardPct, gateFivePct, gateSevenPct); + configured, + snapshot.FiveHour?.Utilization, fiveHour, + snapshot.SevenDay?.Utilization, sevenDay); ReportThrottleTransition(configured, effective, snapshot); return (configured, effective); @@ -343,9 +346,28 @@ public sealed class QueueService : BackgroundService await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true); } + catch (OperationCanceledException) + { + // Cancellation is driven by the cancel path, which already wrote the terminal status. + // Marking the task Failed here would be a regression (it would stomp Cancelled). + _logger.LogInformation("Slot runner cancelled for task {TaskId}", taskId); + } catch (Exception ex) { _logger.LogError(ex, "Slot runner error for task {TaskId}", taskId); + + // The picker already committed status='running' before this ran. Without this the + // task stays Running forever and the UI never hears about it — it keeps showing the + // pre-claim status because the raw-SQL claim itself never broadcasts. + try + { + await _state.FailAsync(taskId, DateTime.UtcNow, + $"Slot runner error: {ex.Message}", CancellationToken.None); + } + catch (Exception failEx) + { + _logger.LogError(failEx, "Could not mark task {TaskId} as failed after a slot error", taskId); + } } } } diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 423ec493..1f5b1442 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -311,6 +311,9 @@ public sealed class TaskRunner { var wtCtx = await _wtManager.CreateAsync(task, list, ct); await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); + // The worktrees row was just inserted; without this the UI keeps showing the task + // as having no worktree until some unrelated event happens to refresh it. + await _broadcaster.WorktreeUpdated(task.Id); return new RunDirResult(wtCtx.WorktreePath, wtCtx, null); } catch (Exception ex) @@ -355,8 +358,6 @@ public sealed class TaskRunner await taskRepo.SetLogPathAsync(taskId, logPath, ct); } - await _broadcaster.RunCreated(taskId, runNumber, isRetry); - var arguments = _argsBuilder.Build(config); await using var logWriter = new LogWriter(logPath); diff --git a/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs b/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs index 52db99fe..b1722d7b 100644 --- a/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs +++ b/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs @@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader if (Directory.Exists(_projectsRoot)) { - foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories)) + // A transcript last written before the window began cannot hold a record inside it, so + // it is skipped unread — that is what keeps a 7-day range off the full history (hundreds + // of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps. + var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1); + + foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories)) { ct.ThrowIfCancellationRequested(); + if (file.LastWriteTime < mtimeCutoff) continue; foreach (var record in GetOrReadFile(file)) { @@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot)) return Task.FromResult(null); - var file = Directory - .EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories) + var file = new DirectoryInfo(_projectsRoot) + .EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories) .FirstOrDefault(); if (file is null) return Task.FromResult(null); @@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader new SessionUsageTotals(input, output, cacheRead, cacheCreation)); } - private List GetOrReadFile(string file) + private List GetOrReadFile(FileInfo info) { - var info = new FileInfo(file); - if (_cache.TryGetValue(file, out var cached) && + if (_cache.TryGetValue(info.FullName, out var cached) && cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc) { return cached.Records; } - var records = ReadFile(file); - _cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records); + var records = ReadFile(info.FullName); + _cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records); return records; } diff --git a/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs b/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs index 2fdbd133..d224b572 100644 --- a/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs +++ b/src/ClaudeDo.Worker/Usage/UsageSnapshotBuilder.cs @@ -49,13 +49,18 @@ public sealed class UsageSnapshotBuilder .Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive)) .ToList(); + var fiveHourThresholds = new UsageThresholds( + settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct); + var sevenDayThresholds = new UsageThresholds( + settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct); + var configuredSlots = Math.Max(1, settings.MaxParallelExecutions); var effectiveSlots = snapshot is null || lastError is not null ? configuredSlots : UsageThrottle.EffectiveSlots( - configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization, - settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct, - settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct); + configuredSlots, + snapshot.FiveHour?.Utilization, fiveHourThresholds, + snapshot.SevenDay?.Utilization, sevenDayThresholds); var throttleBucket = effectiveSlots < configuredSlots ? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization) : null; @@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder lastError, configuredSlots, effectiveSlots, - throttleBucket); + throttleBucket, + fiveHourThresholds.SoftPct, + fiveHourThresholds.HardPct, + sevenDayThresholds.SoftPct, + sevenDayThresholds.HardPct); } private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct) diff --git a/src/ClaudeDo.Worker/Usage/UsageThrottle.cs b/src/ClaudeDo.Worker/Usage/UsageThrottle.cs index da8d349b..c761a2b4 100644 --- a/src/ClaudeDo.Worker/Usage/UsageThrottle.cs +++ b/src/ClaudeDo.Worker/Usage/UsageThrottle.cs @@ -1,37 +1,42 @@ namespace ClaudeDo.Worker.Usage; /// -/// Pure staged throttle sitting ahead of 's hard pause: as the 5h/7d usage -/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of -/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more -/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only — -/// callers with no snapshot at all should skip this and use -/// directly (fail-open), same policy as . +/// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard +/// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage. +/// +public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct); + +/// +/// Pure staged throttle sitting ahead of 's hard pause: as a usage window +/// fills up, the queue's effective parallelism steps down before it hits zero, instead of running +/// at full tilt right up to the gate threshold. Each bucket carries its own thresholds (the 5h and +/// 7d windows fill at very different rates) and the strictest bucket decides. A missing bucket +/// (null) never throttles — callers with no snapshot at all should skip this and use +/// directly (fail-open), same policy as . /// public static class UsageThrottle { public static int EffectiveSlots( int configuredSlots, double? fiveHourPct, + UsageThresholds fiveHour, double? sevenDayPct, - int softPct, - int hardPct, - int gateFiveHourPct, - int gateSevenDayPct) + UsageThresholds sevenDay) { var slots = Math.Max(1, configuredSlots); - if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct) - return 0; - if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct) - return 0; + return Math.Min( + BucketSlots(slots, fiveHourPct, fiveHour), + BucketSlots(slots, sevenDayPct, sevenDay)); + } - var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0); + private static int BucketSlots(int slots, double? pct, UsageThresholds thresholds) + { + if (pct is not { } utilization) return slots; - if (hardPct > 0 && worst >= hardPct) - return Math.Min(slots, 1); - if (softPct > 0 && worst >= softPct) - return Math.Min(slots, 2); + if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0; + if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1); + if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2); return slots; } diff --git a/tests/ClaudeDo.Data.Tests/ModelRegistryTests.cs b/tests/ClaudeDo.Data.Tests/ModelRegistryTests.cs index f38382aa..5e6e6d72 100644 --- a/tests/ClaudeDo.Data.Tests/ModelRegistryTests.cs +++ b/tests/ClaudeDo.Data.Tests/ModelRegistryTests.cs @@ -29,9 +29,9 @@ public class ModelRegistryTests } [Fact] - public void ByCostAscending_is_haiku_sonnet_opus() + public void ByCostAscending_is_haiku_sonnet_opus_fable() { - Assert.Equal(new[] { "haiku", "sonnet", "opus" }, ModelRegistry.ByCostAscending); + Assert.Equal(new[] { "haiku", "sonnet", "opus", "fable" }, ModelRegistry.ByCostAscending); } [Theory] diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs index de8ba656..60370ddb 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs @@ -43,6 +43,33 @@ public class SettingsModalViewModelTests SessionSkills: null, ModelPresets: null, UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct); + [Fact] + public async Task Save_carries_dragged_throttle_stages_through_untouched() + { + // The throttle stages are only editable by dragging the usage-monitor gauges. Saving the + // Settings modal rebuilds the whole DTO, so it must not reset them to the defaults. + var worker = new FakeWorker + { + AppToReturn = DtoWith(65, 95) with + { + UsageThrottleFiveHourSoftPct = 42, + UsageThrottleFiveHourHardPct = 58, + UsageThrottleSevenDaySoftPct = 71, + UsageThrottleSevenDayHardPct = 88, + }, + }; + var vm = MakeVm(worker); + await vm.LoadAsync(); + + await vm.SaveCommand.ExecuteAsync(null); + + Assert.NotNull(worker.Saved); + Assert.Equal(42, worker.Saved!.UsageThrottleFiveHourSoftPct); + Assert.Equal(58, worker.Saved.UsageThrottleFiveHourHardPct); + Assert.Equal(71, worker.Saved.UsageThrottleSevenDaySoftPct); + Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct); + } + private static SettingsModalViewModel MakeVm(FakeWorker worker) => new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(), MakeLocalizer(), new AppSettings()); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs new file mode 100644 index 00000000..99267f15 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs @@ -0,0 +1,153 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +// The delta path in OnWorkerTaskUpdated used to be wrapped in a blank `catch { }`. A single +// transient DB error therefore left the row on its old status forever — the "task stuck on +// Queued although it is running" bug. It must retry, and fall back to a full reload. +public class TasksIslandDeltaResilienceTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandDeltaResilienceTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_delta_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + // Throws on the first N CreateDbContext calls, then behaves normally. + private sealed class FlakyDbFactory : IDbContextFactory + { + private readonly Func _create; + private int _failuresLeft; + public int CreateCalls { get; private set; } + + public FlakyDbFactory(Func create, int failuresLeft) + { + _create = create; + _failuresLeft = failuresLeft; + } + + public ClaudeDoDbContext CreateDbContext() + { + CreateCalls++; + if (_failuresLeft > 0) + { + _failuresLeft--; + throw new InvalidOperationException("simulated transient DB failure"); + } + return _create(); + } + + public void FailNext() => _failuresLeft++; + } + + private sealed class FakeWorker : StubWorkerClient + { + } + + // A user list's nav id is prefixed — see TasksIslandRegroupTests.UserList. + private static ListNavItemViewModel UserList(string listEntityId, string name) => + new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; + + // LoadForList is void and fires a background task; this is the wait idiom the other + // TasksIsland test files use. + private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list) + { + vm.LoadForList(list); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(25); + if (vm.Items.Count > 0) break; + } + await Task.Delay(50); + } + + private async Task SeedAsync() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity + { + Id = "T1", ListId = "L1", Title = "Task one", + Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0, + }); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task Delta_refresh_retries_after_a_transient_failure_and_still_applies_the_new_status() + { + await SeedAsync(); + + var flaky = new FlakyDbFactory(NewContext, failuresLeft: 0); + var vm = new TasksIslandViewModel(flaky, new FakeWorker()); + var list = UserList("L1", "Work"); + + await LoadAndWaitAsync(vm, list); + Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status); + + // Worker flips the task to Running. + await using (var db = NewContext()) + { + var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); + t.Status = TaskStatus.Running; + await db.SaveChangesAsync(); + } + + // The next delta read fails once; the retry must still land the new status. + flaky.FailNext(); + await vm.RefreshTaskFromWorkerAsync("T1"); + + Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); + } + + [Fact] + public async Task A_stale_delta_result_does_not_overwrite_a_newer_one() + { + await SeedAsync(); + + var factory = new FlakyDbFactory(NewContext, failuresLeft: 0); + var vm = new TasksIslandViewModel(factory, new FakeWorker()); + var list = UserList("L1", "Work"); + await LoadAndWaitAsync(vm, list); + + // Start refresh #1 while the DB still says Queued, but do not await it yet. + var first = vm.RefreshTaskFromWorkerAsync("T1"); + + await using (var db = NewContext()) + { + var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); + t.Status = TaskStatus.Running; + await db.SaveChangesAsync(); + } + + // Refresh #2 sees Running and must win, regardless of completion order. + var second = vm.RefreshTaskFromWorkerAsync("T1"); + + await Task.WhenAll(first, second); + + Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs index 316fc57f..7d16b339 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs @@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests public int RefreshCalls; public Exception? RefreshThrows; - public override Task GetUsageSnapshotAsync() => Task.FromResult(Snapshot); + /// When set, the snapshot fetch never completes — stands in for the slow first + /// transcript scan on the worker side. + public TaskCompletionSource? SnapshotGate; + public Exception? SnapshotThrows; + + public override Task GetUsageSnapshotAsync() + { + if (SnapshotThrows is not null) throw SnapshotThrows; + return SnapshotGate?.Task ?? Task.FromResult(Snapshot); + } public override Task RefreshUsageAsync() { @@ -53,8 +62,25 @@ public class UsageMonitorModalViewModelTests TaskUsageCalls++; return Task.FromResult(TaskRows); } + + public AppSettingsDto? AppSettings; + public AppSettingsDto? SavedSettings; + + public override Task GetAppSettingsAsync() => Task.FromResult(AppSettings); + + public override Task UpdateAppSettingsAsync(AppSettingsDto dto) + { + SavedSettings = dto; + return Task.CompletedTask; + } } + private static AppSettingsDto AppSettings() => + new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30, + DefaultPermissionMode: "auto", MaxParallelExecutions: 3, WorktreeStrategy: "sibling", + CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7, + ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5); + private static UsageLimitDto Limit( string kind, double percent = 10, string severity = "normal", DateTimeOffset? resetsAt = null, string? scopeModelDisplayName = null, bool isActive = true) @@ -71,13 +97,63 @@ public class UsageMonitorModalViewModelTests DateTime? fetchedAtUtc = null, int configuredSlots = 1, int effectiveSlots = 1, - string? throttleBucket = null) + string? throttleBucket = null, + int throttleFiveHourSoftPct = 50, + int throttleFiveHourHardPct = 65, + int throttleSevenDaySoftPct = 50, + int throttleSevenDayHardPct = 65) => new( null, null, null, null, limits ?? Array.Empty(), fiveHourThresholdPct, sevenDayThresholdPct, isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError, - configuredSlots, effectiveSlots, throttleBucket); + configuredSlots, effectiveSlots, throttleBucket, + throttleFiveHourSoftPct, throttleFiveHourHardPct, + throttleSevenDaySoftPct, throttleSevenDayHardPct); + + // ── BeginLoad: the modal must open before the data lands ──────────────── + + [Fact] + public void BeginLoad_ReturnsWhileWorkerStillPending_AndShowsBusy() + { + var worker = new FakeWorker { SnapshotGate = new TaskCompletionSource() }; + var vm = new UsageMonitorModalViewModel(worker); + + vm.BeginLoad(); + + Assert.True(vm.IsBusy); + Assert.False(vm.ModelsEmpty); + Assert.False(vm.TasksEmpty); + } + + [Fact] + public void BeginLoad_WorkerThrows_ReportsErrorInsteadOfCrashing() + { + var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") }; + var vm = new UsageMonitorModalViewModel(worker); + string? reported = null; + vm.ErrorReported += m => reported = m; + + vm.BeginLoad(); + + Assert.NotNull(reported); + Assert.Contains("worker offline", reported); + Assert.False(vm.IsBusy); + } + + [Fact] + public async Task LoadAsync_WorkerThrows_ReportsErrorAndClearsBusy() + { + var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") }; + var vm = new UsageMonitorModalViewModel(worker); + string? reported = null; + vm.ErrorReported += m => reported = m; + + await vm.LoadAsync(); + + Assert.NotNull(reported); + Assert.False(vm.IsBusy); + } // ── Manual refresh ────────────────────────────────────────────────────── @@ -210,7 +286,7 @@ public class UsageMonitorModalViewModelTests var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); - Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent); + Assert.Equal(80, vm.GaugeRows[0].GatePct); } [Fact] @@ -220,7 +296,276 @@ public class UsageMonitorModalViewModelTests var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); - Assert.Null(vm.GaugeRows[0].ThresholdPercent); + Assert.Null(vm.GaugeRows[0].GatePct); + Assert.False(vm.GaugeRows[0].IsAdjustable); + } + + // ── Draggable stage markers ───────────────────────────────────────────── + + [Fact] + public async Task GaugeRow_Session_CarriesPerBucketThrottleStages() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80, + throttleFiveHourSoftPct: 45, throttleFiveHourHardPct: 60), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + var row = vm.GaugeRows[0]; + Assert.Equal("five_hour", row.Bucket); + Assert.Equal(45, row.SoftPct); + Assert.Equal(60, row.HardPct); + Assert.Equal(80, row.GatePct); + Assert.True(row.IsAdjustable); + } + + [Fact] + public async Task GaugeRow_WeeklyAll_CarriesSevenDayStages() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90, + throttleSevenDaySoftPct: 70, throttleSevenDayHardPct: 85), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + var row = vm.GaugeRows[0]; + Assert.Equal("seven_day", row.Bucket); + Assert.Equal(70, row.SoftPct); + Assert.Equal(85, row.HardPct); + Assert.Equal(90, row.GatePct); + } + + [Fact] + public async Task Commit_WritesOnlyTheDraggedBucket_AndKeepsEverythingElse() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80, + throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65), + AppSettings = AppSettings(), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + var row = vm.GaugeRows[0]; + row.SoftPct = 40; + row.HardPct = 55; + row.GatePct = 75; + await row.CommitCommand.ExecuteAsync(null); + + Assert.NotNull(worker.SavedSettings); + Assert.Equal(40, worker.SavedSettings!.UsageThrottleFiveHourSoftPct); + Assert.Equal(55, worker.SavedSettings.UsageThrottleFiveHourHardPct); + Assert.Equal(75, worker.SavedSettings.UsageGateFiveHourPct); + // The 7d bucket and unrelated settings ride along untouched. + Assert.Equal(50, worker.SavedSettings.UsageThrottleSevenDaySoftPct); + Assert.Equal(65, worker.SavedSettings.UsageThrottleSevenDayHardPct); + Assert.Equal(90, worker.SavedSettings.UsageGateSevenDayPct); + Assert.Equal(3, worker.SavedSettings.MaxParallelExecutions); + } + + [Fact] + public async Task Commit_WorkerOffline_ReportsErrorAndSavesNothing() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("session") }), + AppSettings = null, + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + string? reported = null; + vm.ErrorReported += m => reported = m; + + await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null); + + Assert.NotNull(reported); + Assert.Null(worker.SavedSettings); + } + + [Fact] + public async Task Commit_NonAdjustableRow_SavesNothing() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }), + AppSettings = AppSettings(), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null); + + Assert.Null(worker.SavedSettings); + } + + [Fact] + public async Task LiveSnapshot_UpdatesRowsInPlace_WithoutReplacingInstances() + { + // A poll landing mid-interaction must not swap the row the gauge is bound to. + var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session", percent: 20) }) }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + var before = vm.GaugeRows[0]; + + vm.Snapshot = Snapshot(new[] { Limit("session", percent: 55) }, throttleFiveHourSoftPct: 44); + + Assert.Same(before, vm.GaugeRows[0]); + Assert.Equal(55, vm.GaugeRows[0].Percent); + Assert.Equal(44, vm.GaugeRows[0].SoftPct); + } + + [Fact] + public async Task LiveSnapshot_NewLimitKind_AddsARow() + { + var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + vm.Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all") }); + + Assert.Equal(2, vm.GaugeRows.Count); + } + + // ── Legend input boxes ─────────────────────────────────────────────────── + + [Fact] + public async Task TypedStage_SavesTheEditedBucket() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80), + AppSettings = AppSettings(), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + var row = vm.GaugeRows[0]; + row.HardPct = 58; + await row.CommitHardCommand.ExecuteAsync(null); + + Assert.Equal(58, worker.SavedSettings!.UsageThrottleFiveHourHardPct); + Assert.Equal(50, worker.SavedSettings.UsageThrottleFiveHourSoftPct); + Assert.Equal(80, worker.SavedSettings.UsageGateFiveHourPct); + } + + [Fact] + public async Task TypedStage_OutOfOrder_IsPinned_AndLeavesNeighboursAlone() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80, + throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65), + AppSettings = AppSettings(), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + // Typing 95 into the soft box may not push past hard — and must not drag hard along. + var row = vm.GaugeRows[0]; + row.SoftPct = 95; + await row.CommitSoftCommand.ExecuteAsync(null); + + Assert.Equal(65, row.SoftPct); + Assert.Equal(65, row.HardPct); + Assert.Equal(80, row.GatePct); + Assert.Equal(65, worker.SavedSettings!.UsageThrottleFiveHourSoftPct); + } + + [Fact] + public async Task TypedGate_BelowHard_IsPinnedToHard() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90, + throttleSevenDaySoftPct: 50, throttleSevenDayHardPct: 65), + AppSettings = AppSettings(), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + var row = vm.GaugeRows[0]; + row.GatePct = 20; + await row.CommitGateCommand.ExecuteAsync(null); + + Assert.Equal(65, row.GatePct); + Assert.Equal(65, worker.SavedSettings!.UsageGateSevenDayPct); + } + + [Fact] + public async Task TypedStage_OnNonAdjustableRow_SavesNothing() + { + var worker = new FakeWorker + { + Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }), + AppSettings = AppSettings(), + }; + var vm = new UsageMonitorModalViewModel(worker); + await vm.LoadAsync(); + + await vm.GaugeRows[0].CommitSoftCommand.ExecuteAsync(null); + + Assert.Null(worker.SavedSettings); + } + + // ── Drag math ──────────────────────────────────────────────────────────── + + [Theory] + [InlineData(30, UsageThresholdDrag.Stage.Soft, 30, 65, 80)] // free move below hard + [InlineData(90, UsageThresholdDrag.Stage.Soft, 65, 65, 80)] // pinned to hard + [InlineData(-5, UsageThresholdDrag.Stage.Soft, 0, 65, 80)] // clamped at 0 + [InlineData(70, UsageThresholdDrag.Stage.Hard, 50, 70, 80)] // free move between soft and gate + [InlineData(10, UsageThresholdDrag.Stage.Hard, 50, 50, 80)] // pinned to soft + [InlineData(95, UsageThresholdDrag.Stage.Hard, 50, 80, 80)] // pinned to gate + [InlineData(120, UsageThresholdDrag.Stage.Gate, 50, 65, 100)] // clamped at 100 + [InlineData(20, UsageThresholdDrag.Stage.Gate, 50, 65, 65)] // pinned to hard + public void Drag_KeepsStagesOrderedAndInRange( + double dragTo, UsageThresholdDrag.Stage stage, int expectedSoft, int expectedHard, int expectedGate) + { + var result = UsageThresholdDrag.Apply(50, 65, 80, stage, dragTo); + + Assert.Equal((expectedSoft, expectedHard, expectedGate), result); + } + + [Fact] + public void Drag_RoundsToWholePercent() + { + Assert.Equal((37, 65, 80), UsageThresholdDrag.Apply(50, 65, 80, UsageThresholdDrag.Stage.Soft, 36.7)); + } + + [Fact] + public void Drag_NeighbourAtZeroIsOff_AndDoesNotPinTheMarker() + { + // hard = 0 means "hard stage off" — soft must still be draggable up to the gate. + Assert.Equal((70, 0, 80), UsageThresholdDrag.Apply(50, 0, 80, UsageThresholdDrag.Stage.Soft, 70)); + } + + [Fact] + public void Drag_InconsistentStoredConfig_DoesNotThrow() + { + // soft above gate (only reachable by hand-editing the DB) must degrade, not crash. + var result = UsageThresholdDrag.Apply(90, 95, 50, UsageThresholdDrag.Stage.Hard, 60); + + Assert.Equal(50, result.Hard); + } + + [Theory] + [InlineData(50, UsageThresholdDrag.Stage.Soft)] + [InlineData(63, UsageThresholdDrag.Stage.Hard)] + [InlineData(82, UsageThresholdDrag.Stage.Gate)] + public void Nearest_PicksTheClosestMarkerInReach(double percent, UsageThresholdDrag.Stage expected) + { + Assert.Equal(expected, UsageThresholdDrag.Nearest(50, 65, 80, percent, tolerancePercent: 5)); + } + + [Fact] + public void Nearest_OutOfReach_GrabsNothing() + { + Assert.Null(UsageThresholdDrag.Nearest(50, 65, 80, percent: 20, tolerancePercent: 5)); } // ── Stale / gate bands ─────────────────────────────────────────────────── diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index e6d306d3..4f7ef325 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -989,19 +989,6 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal(10, result.Config.MaxTurns); } - // ── GetTaskStatusValues ─────────────────────────────────────────────────── - - [Fact] - public async Task GetTaskStatusValues_ContainsAllStatuses() - { - var sut = NewService(); - var values = await sut.GetTaskStatusValues(); - var names = values.Select(v => v.Status).ToHashSet(); - - foreach (var status in Enum.GetValues()) - Assert.Contains(status.ToString(), names); - } - // ── ListTasks status filter ─────────────────────────────────────────────── [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs index 230c8d39..69ef045f 100644 --- a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs @@ -100,8 +100,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.MaxParallelExecutions = maxParallel; - settings.UsageThrottleSoftPct = softPct; - settings.UsageThrottleHardPct = hardPct; + settings.UsageThrottleFiveHourSoftPct = softPct; + settings.UsageThrottleFiveHourHardPct = hardPct; + settings.UsageThrottleSevenDaySoftPct = softPct; + settings.UsageThrottleSevenDayHardPct = hardPct; await repo.UpdateAsync(settings); } diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs index 1edbe5c0..ca2bce98 100644 --- a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs @@ -1,6 +1,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Online; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; @@ -55,7 +56,8 @@ public sealed class OnlineSyncServiceTests : IDisposable } } - private OnlineSyncService BuildService(FakeApi api, string? token = "test-token", bool enabled = true) + private OnlineSyncService BuildService( + FakeApi api, string? token = "test-token", bool enabled = true, HubBroadcaster? broadcaster = null) { var config = new OnlineInboxConfig { Enabled = enabled, PollIntervalSeconds = 60 }; var auth = new StaticTokenAuthProvider(token); @@ -64,7 +66,8 @@ public sealed class OnlineSyncServiceTests : IDisposable api, auth, config, - NullLogger.Instance); + NullLogger.Instance, + broadcaster ?? new HubBroadcaster(new CapturingHubContext())); } private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync() @@ -103,6 +106,26 @@ public sealed class OnlineSyncServiceTests : IDisposable Assert.Contains(remoteId, api.MarkedImported); } + [Fact] + public async Task Tick_Imports_RemoteTask_BroadcastsTaskUpdated() + { + var (listId, ctx, _, _) = await SeedAsync(); + using var _ = ctx; + + var remoteId = Guid.NewGuid().ToString(); + var api = new FakeApi + { + UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)], + }; + var hubContext = new CapturingHubContext(); + var svc = BuildService(api, broadcaster: new HubBroadcaster(hubContext)); + + await svc.TickAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == remoteId); + } + [Fact] public async Task Tick_UnknownList_Skips_And_DoesNotMark() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs index ecee41dd..83b4e465 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs @@ -121,4 +121,52 @@ public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable releaseProcess.TrySetResult(); await runTask; } + + [Fact] + public async Task Creating_a_worktree_broadcasts_WorktreeUpdated() + { + string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); + var repoDir = Path.Combine(_tempDir, "repo"); + Directory.CreateDirectory(repoDir); + + // A real git repo — Worker.Tests run real git by design. + await RunGitAsync(repoDir, "init"); + await RunGitAsync(repoDir, "config user.email t@t.t"); + await RunGitAsync(repoDir, "config user.name t"); + await File.WriteAllTextAsync(Path.Combine(repoDir, "a.txt"), "hi"); + await RunGitAsync(repoDir, "add a.txt"); + await RunGitAsync(repoDir, "commit -m init"); + + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running, + StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); + var runner = BuildRunner(fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "queue", + CancellationToken.None, alreadyClaimed: true); + + Assert.Contains(_hubContext.Proxy.Calls, + c => c.Method == "WorktreeUpdated" && (string)c.Args[0]! == taskId); + } + + private static async Task RunGitAsync(string dir, string args) + { + var psi = new System.Diagnostics.ProcessStartInfo("git", args) + { + WorkingDirectory = dir, RedirectStandardOutput = true, RedirectStandardError = true, + }; + using var p = System.Diagnostics.Process.Start(psi)!; + await p.WaitForExitAsync(); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs new file mode 100644 index 00000000..bf604e78 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs @@ -0,0 +1,223 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Queue; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Usage; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Services; + +// The queue picker's raw-SQL claim commits status='running' before the runner starts. If +// anything then throws before the runner's own terminal-status write, the task used to stay +// Running forever with the UI never notified (RunInSlotAsync's catch only logged the error). +// It must now mark the task Failed for a real exception (which broadcasts TaskUpdated), but +// must NOT do so for a cancellation — the cancel path already wrote the terminal status. +// +// These drive the real QueueService end to end (StartAsync + the waker), not just the +// FailAsync contract, so they actually exercise the fixed catch block. +public sealed class QueueServiceSlotFailureTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _tempDir; + private readonly WorkerConfig _cfg; + + public QueueServiceSlotFailureTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotfail_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(_tempDir, "sandbox"), + LogRoot = Path.Combine(_tempDir, "logs"), + QueueBackstopIntervalMs = 50, // fast for tests + }; + } + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_tempDir, true); } catch { } + } + + // Mirrors QueueServiceTests.CreateService but takes the picker as a parameter so each test + // can engineer the exact failure path it needs to exercise. + // Build() wires its own CapturingHubContext internally and hands it back as .Hub — the + // broadcaster inside TaskStateService (and therefore FailAsync's TaskUpdated) uses that + // exact instance, so everything else here must share it too rather than constructing a + // second CapturingHubContext that would silently miss FailAsync's broadcast. + private (QueueService service, CapturingHubContext hub, QueueWaker waker) CreateService(IQueuePicker picker) + { + var dbFactory = _db.CreateFactory(); + var built = TaskStateServiceBuilder.Build(dbFactory); + var broadcaster = new HubBroadcaster(built.Hub); + var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); + var argsBuilder = new ClaudeArgsBuilder(); + var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, argsBuilder, _cfg, + NullLogger.Instance, built.State, new TaskRunTokenRegistry(), new AttachmentStore(), + new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); + var waker = new QueueWaker(); + var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); + var service = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance, waker, picker, + overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), new UsageState(), broadcaster); + return (service, built.Hub, waker); + } + + private async Task SeedListAsync() + { + var listId = Guid.NewGuid().ToString(); + using var ctx = _db.CreateContext(); + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + return listId; + } + + // Directly rewrites the task's list_id via a raw connection with FK enforcement off, + // reproducing "the list vanished between the queue claim and the run" without going + // through EF's foreign-key-checked connections (which would reject the write). + private void OrphanTaskListId(string taskId) + { + using var conn = new SqliteConnection($"Data Source={_db.DbPath}"); + conn.Open(); + using (var pragmaCmd = conn.CreateCommand()) + { + pragmaCmd.CommandText = "PRAGMA foreign_keys=OFF;"; + pragmaCmd.ExecuteNonQuery(); + } + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE tasks SET list_id = 'orphaned-missing-list' WHERE id = $id;"; + cmd.Parameters.AddWithValue("$id", taskId); + cmd.ExecuteNonQuery(); + } + + [Fact] + public async Task A_throwing_slot_run_marks_the_task_Failed_and_broadcasts_TaskUpdated() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + + using (var ctx = _db.CreateContext()) + { + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued, + ReviewFeedback = "please fix", CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + + // A prior run with a session id routes RunInSlotAsync into TaskRunner.ContinueAsync + // instead of RunAsync. + await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, + Prompt = "original", SessionId = "sess-1", StartedAt = DateTime.UtcNow.AddMinutes(-5), + }); + } + + // ContinueAsync's setup block reads the list *before* its own try/catch starts + // (TaskRunner.cs, ContinueAsync ~line 232-234) and throws InvalidOperationException + // ("List not found.") straight past TaskRunner's own protection. That's the exact gap + // QueueService.RunInSlotAsync's own catch now has to cover. + OrphanTaskListId(taskId); + + var (service, hub, waker) = CreateService(new QueuePicker(_db.CreateFactory())); + + using var cts = new CancellationTokenSource(); + await service.StartAsync(cts.Token); + waker.Wake(); + + // FailAsync (TaskStateService.cs:236-249) commits the DB status flip via + // ExecuteUpdateAsync *before* it calls the broadcaster's TaskUpdated — so a poll that + // breaks the instant it observes Status==Failed can race ahead of the broadcast still + // landing in hub.Proxy.Calls. Wait for both signals together so the assertions below + // never sample a genuinely-not-yet-broadcast window as a failure. + TaskEntity? reloaded = null; + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline) + { + using var verify = _db.CreateContext(); + reloaded = await new TaskRepository(verify).GetByIdAsync(taskId); + var broadcastSeen = hub.Proxy.Calls.Any( + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); + if (reloaded!.Status == TaskStatus.Failed && broadcastSeen) break; + await Task.Delay(25); + } + cts.Cancel(); + + Assert.Equal(TaskStatus.Failed, reloaded!.Status); + Assert.Contains(hub.Proxy.Calls, + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); + } + + // A fake IQueuePicker that performs the real atomic claim (so the DB row transitions + // Queued->Running exactly like production) and then, synchronously before returning, + // cancels the token QueueService's per-slot CTS is linked from. By the time + // QueueService.ExecuteAsync creates that linked CTS and dispatches RunInSlotAsync, the + // token is already cancelled — deterministic, no timing race required. + private sealed class ClaimThenCancelPicker : IQueuePicker + { + private readonly IQueuePicker _inner; + private readonly CancellationTokenSource _cancelAfterClaim; + + public ClaimThenCancelPicker(IQueuePicker inner, CancellationTokenSource cancelAfterClaim) + { + _inner = inner; + _cancelAfterClaim = cancelAfterClaim; + } + + public async Task ClaimNextAsync(DateTime now, CancellationToken ct) + { + var claimed = await _inner.ClaimNextAsync(now, ct); + if (claimed is not null) _cancelAfterClaim.Cancel(); + return claimed; + } + } + + [Fact] + public async Task A_cancelled_slot_run_does_not_mark_the_task_Failed() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + + using (var ctx = _db.CreateContext()) + { + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued, + CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var outerCts = new CancellationTokenSource(); + var realPicker = new QueuePicker(_db.CreateFactory()); + var picker = new ClaimThenCancelPicker(realPicker, outerCts); + var (service, hub, waker) = CreateService(picker); + + await service.StartAsync(outerCts.Token); + waker.Wake(); + + // Wait for the slot to be claimed and then released again (RunInSlotAsync's + // ContinueWith removes it once the catch block — ours or a stray one — finishes). + var deadline = DateTime.UtcNow.AddSeconds(10); + while (service.GetActive().Any(a => a.taskId == taskId) && DateTime.UtcNow < deadline) + await Task.Delay(25); + await Task.Delay(100); // let the fire-and-forget continuation fully settle + + TaskEntity? reloaded; + using (var verify = _db.CreateContext()) + reloaded = await new TaskRepository(verify).GetByIdAsync(taskId); + + // The picker's atomic claim already flipped it to Running; the cancelled slot run must + // leave it there rather than flipping it to Failed. + Assert.Equal(TaskStatus.Running, reloaded!.Status); + Assert.DoesNotContain(hub.Proxy.Calls, + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 527166dc..e0cf4a92 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -81,8 +81,10 @@ public sealed class QueueServiceTests : IDisposable var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.MaxParallelExecutions = maxParallel; - settings.UsageThrottleSoftPct = softPct; - settings.UsageThrottleHardPct = hardPct; + settings.UsageThrottleFiveHourSoftPct = softPct; + settings.UsageThrottleFiveHourHardPct = hardPct; + settings.UsageThrottleSevenDaySoftPct = softPct; + settings.UsageThrottleSevenDayHardPct = hardPct; settings.UsageGateFiveHourPct = gateFive; settings.UsageGateSevenDayPct = gateSeven; await repo.UpdateAsync(settings); diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs index 6a6cdf20..d18ff72e 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs @@ -139,6 +139,35 @@ public class TranscriptUsageReaderTests : IDisposable Assert.Equal(1, row.Messages); } + [Fact] + public async Task Files_Last_Written_Before_The_Window_Are_Not_Read() + { + // Deliberate heuristic: a transcript whose mtime predates the window cannot contain a + // record inside it, so it is skipped unread. Here the content would match the window — + // proving the file was never opened, which is what keeps a 7-day range off the full history. + var path = WriteSession("proj", "old.jsonl", + AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0)); + File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0)); + + var reader = MakeReader(); + var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); + + Assert.Empty(result); + } + + [Fact] + public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read() + { + var path = WriteSession("proj", "edge.jsonl", + AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0)); + File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0)); + + var reader = MakeReader(); + var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); + + Assert.Single(result); + } + [Fact] public async Task Malformed_Line_Does_Not_Abort_The_Run() { diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs index 4ff5ef7e..f2e5b4ab 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageSnapshotBuilderTests.cs @@ -149,11 +149,61 @@ public sealed class UsageSnapshotBuilderTests : IDisposable var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.MaxParallelExecutions = maxParallel; - settings.UsageThrottleSoftPct = softPct; - settings.UsageThrottleHardPct = hardPct; + settings.UsageThrottleFiveHourSoftPct = softPct; + settings.UsageThrottleFiveHourHardPct = hardPct; + settings.UsageThrottleSevenDaySoftPct = softPct; + settings.UsageThrottleSevenDayHardPct = hardPct; await repo.UpdateAsync(settings); } + private async Task SetPerBucketThrottleAsync( + int maxParallel, int fiveSoft, int fiveHard, int sevenSoft, int sevenHard) + { + using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + var settings = await repo.GetAsync(); + settings.MaxParallelExecutions = maxParallel; + settings.UsageThrottleFiveHourSoftPct = fiveSoft; + settings.UsageThrottleFiveHourHardPct = fiveHard; + settings.UsageThrottleSevenDaySoftPct = sevenSoft; + settings.UsageThrottleSevenDayHardPct = sevenHard; + await repo.UpdateAsync(settings); + } + + [Fact] + public async Task Per_bucket_throttle_stages_are_reported_for_the_gauges() + { + await SetThresholdsAsync(80, 90); + await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 60, sevenSoft: 70, sevenHard: 85); + + var state = new UsageState(); + state.ReportSuccess(new UsageSnapshot( + new UsageBucket(10, null), new UsageBucket(10, null), Array.Empty(), DateTime.UtcNow)); + + var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync(); + + Assert.Equal(45, dto.ThrottleFiveHourSoftPct); + Assert.Equal(60, dto.ThrottleFiveHourHardPct); + Assert.Equal(70, dto.ThrottleSevenDaySoftPct); + Assert.Equal(85, dto.ThrottleSevenDayHardPct); + } + + [Fact] + public async Task Per_bucket_stages_apply_independently_to_effective_slots() + { + await SetThresholdsAsync(80, 90); + // Both buckets sit at 60%: past the 5h soft stage (45) but below every 7d stage (70/85). + await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 90, sevenSoft: 70, sevenHard: 85); + + var state = new UsageState(); + state.ReportSuccess(new UsageSnapshot( + new UsageBucket(60, null), new UsageBucket(60, null), Array.Empty(), DateTime.UtcNow)); + + var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync(); + + Assert.Equal(2, dto.EffectiveSlots); + } + [Fact] public async Task Throttled_slots_and_decisive_bucket_reported() { diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs index fb82db1c..1a1e683e 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageThrottleTests.cs @@ -4,13 +4,11 @@ namespace ClaudeDo.Worker.Tests.Usage; public sealed class UsageThrottleTests { - private const int Soft = 50; - private const int Hard = 65; - private const int GateFive = 80; - private const int GateSeven = 90; + private static readonly UsageThresholds FiveHour = new(SoftPct: 50, HardPct: 65, GatePct: 80); + private static readonly UsageThresholds SevenDay = new(SoftPct: 50, HardPct: 65, GatePct: 90); private static int Effective(double? five, double? seven, int configured = 3) => - UsageThrottle.EffectiveSlots(configured, five, seven, Soft, Hard, GateFive, GateSeven); + UsageThrottle.EffectiveSlots(configured, five, FiveHour, seven, SevenDay); [Fact] public void BelowSoftThreshold_ReturnsFullConfiguredSlots() @@ -94,13 +92,19 @@ public sealed class UsageThrottleTests [Fact] public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate() { - Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, 89, softPct: 0, hardPct: 0, gateFiveHourPct: GateFive, gateSevenDayPct: GateSeven)); + var five = new UsageThresholds(0, 0, 80); + var seven = new UsageThresholds(0, 0, 90); + + Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, five, 89, seven)); } [Fact] public void ZeroGateThresholds_NeverHardBlock() { - Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, 99, softPct: Soft, hardPct: Hard, gateFiveHourPct: 0, gateSevenDayPct: 0)); + var five = new UsageThresholds(50, 65, 0); + var seven = new UsageThresholds(50, 65, 0); + + Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, five, 99, seven)); } [Fact] @@ -108,4 +112,46 @@ public sealed class UsageThrottleTests { Assert.Equal(1, Effective(10, 10, configured: 0)); } + + // ── Per-bucket thresholds are independent ─────────────────────────────── + + [Fact] + public void PerBucket_SameUtilization_DifferentStagesPerBucket() + { + // 60% is past the 5h soft (50) but still under the 7d soft (70): the 5h bucket decides. + var five = new UsageThresholds(50, 65, 80); + var seven = new UsageThresholds(70, 85, 90); + + Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 60, five, 60, seven)); + } + + [Fact] + public void PerBucket_LessUtilizedBucketCanStillBeTheStricterOne() + { + // 7d sits lower (40%) but has the tighter thresholds, so it — not the busier 5h — throttles. + var five = new UsageThresholds(90, 95, 99); + var seven = new UsageThresholds(20, 35, 90); + + Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 80, five, 40, seven)); + } + + [Fact] + public void PerBucket_StrictestStageWins() + { + // 5h is only in its soft stage (2 slots), 7d is past its hard stage (1 slot) → 1 wins. + var five = new UsageThresholds(50, 65, 80); + var seven = new UsageThresholds(30, 40, 90); + + Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 55, five, 45, seven)); + } + + [Fact] + public void PerBucket_MissingBucketNeverThrottles() + { + // No 7d reading at all: only the 5h bucket may step parallelism down. + var five = new UsageThresholds(50, 65, 80); + var seven = new UsageThresholds(1, 2, 3); + + Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 55, five, null, seven)); + } }