fix:Improve Claude Mds

This commit is contained in:
Mika Kuns
2026-08-05 16:46:02 +02:00
parent f6cb8250bb
commit d15aa27707
9 changed files with 307 additions and 272 deletions
+108 -177
View File
@@ -2,112 +2,71 @@
ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.
**Deeper detail lives in `docs/explore-notes/`** — read the matching note before deep-exploring:
[worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md) ·
[usage-monitoring](../../docs/explore-notes/usage-monitoring.md) ·
[external-mcp](../../docs/explore-notes/external-mcp.md) ·
[review-merge](../../docs/explore-notes/review-merge.md) ·
[conpty-sessions](../../docs/explore-notes/conpty-sessions.md)
## Folder Layout
```
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists), PromptFileRecovery (startup sweep: `PromptFiles.ReconcileStaleDefaults()` drops any prompt override that only matched a now-superseded default and was never actually edited, `QuarantineOrphans()` moves *.md files under `prompts/` with no matching `PromptKind` into `prompts/_orphans/`)
Queue/ — IQueueWaker, IQueuePicker, QueueService, OverrideSlotService, RunCancellationRegistry
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner,
ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery,
AttachmentOrphanRecovery, PromptFileRecovery (last four = startup sweeps)
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning sessions
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
External/ — ExternalMcpService + sibling tool classes
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/Context/TokenRegistry (in-task MCP)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService,
PlanningMergeOrchestrator, PlanningAggregator, InteractiveLaunchSpecService,
session-context/token-auth types, WindowsTerminalLauncher (ITerminalLauncher)
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`)
External/ — ExternalMcpService + sibling tool classes (always-on MCP for general sessions)
Config/ — WorkerConfig
Hub/ — WorkerHub, HubBroadcaster
Logging/ — LogRingBuffer (30-min in-memory log window) + BroadcastLogSink (Serilog sink → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache; `<synthetic>`-model lines are skipped everywhere, not real API calls; also exposes `ReadSessionTotalsAsync(sessionId)` — cumulative raw input/output/cache-read/cache-write totals for one session, located by `{sessionId}.jsonl`, used by `TaskRunner` to populate `task_runs`' per-run token fields), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked), UsageThrottle (pure static `EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, gateFiveHourPct, gateSevenDayPct)` — stages parallelism down ahead of the hard gate: whichever bucket is more utilized decides the stage, `0` for a threshold disables that stage, `>= softPct` caps at 2 slots, `>= hardPct` caps at 1, `>= either gate threshold` returns 0 — same 0 as `UsageGate`'s hard block, kept in sync since both read the same gate thresholds); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate)
Logging/ — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService
Prime/ — daily prep ("Prime Claude"): PrimeScheduler, PrimeRunner, DailyPrepPrompt,
NextDueCalculator, PrimeScheduleSignal
Online/ — optional Online Inbox sync (off by default; zero network when disabled)
Usage/ — OAuth usage monitor, gate, throttle, transcript token reader
```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
Interfaces (`IQueueWaker`, `IPrimeClock`, `ITaskStateService`, …) live in an `Interfaces/`
subfolder within their area; the namespace stays the area namespace.
## Architecture
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. On each loop tick, `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot (a missing/failed snapshot fails open to the configured value — never throttles on a broken poll); a stage change (not every tick) logs once via the standard logger. Separately, it also asks `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped entirely for that tick (already-running slots are untouched in either case; `RunNow`/`ContinueTask`/interactive/planning/daily-prep all bypass the queue and are unaffected). A blocked↔free transition is logged/broadcast (`WorkerLog`, Warn on block / Info on resume) exactly once per change, not on every tick; the 30 s backstop timer re-evaluates both the throttle and the gate on its own even with no wake signal, so the queue self-recovers once usage drops back under the threshold.
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **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 service; 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 worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." **No external tool returns bare `Task` or a nullable payload directly** — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record (`{ ok/deleted/removed/reset/started: true, <id>, ... }`, e.g. `DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`, `RemoveAttachmentResult`; `SetListConfigResult`/`SetTaskConfigResult` additionally echo the resulting config so the caller can see which fields were set vs. cleared to null); read tools that may have nothing to return use an explicit `Found`/`Available` flag alongside the nullable payload (`TaskConfigResult`, pre-existing `BatchGetTaskResult`, `TaskLogResult`) instead of returning null outright. Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done``Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, `PreviewMerge` (non-destructive `git merge-tree --write-tree` mergeability check for one task's worktree branch against `targetBranch`, default the repo's current branch — status/conflictFiles/changedFileCount plus `behind`; throws a clear error instead of TaskMergeService.PreviewAsync's silent "unavailable" when the task has no worktree, the worktree isn't Active, or the list's working dir is missing), `PreviewMergeSet` (same preview for a batch of task ids plus a file→tasks overlap report built from each task's own diff-stat — a same-file-name hint only, blind to cross-file collisions like the CS0103 case that motivated it; a task that fails to preview gets `error` set and is excluded from the overlap instead of aborting the batch), `RevertMerge` (undoes a previously merged task's merge commit on `targetBranch` via `git revert -m 1` — a new commit, never a reset/rewrite, since the target working directory is shared with other sessions; requires the task to be `Done` with a `Merged` worktree carrying a recorded `WorktreeEntity.MergeCommit` — a task merged before that field existed has none and is refused rather than guessed via `git log`; on success the task returns to `WaitingForReview` and the worktree moves to `Kept`, not `Active` (its directory/branch are typically already gone from the original merge's cleanup) and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those); a conflicting revert is aborted immediately, no half-resolved state is ever left in the tree), `ListWorktrees`, `CleanupTaskWorktree`
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only; includes `MaxParallelExecutions`)
- `TaskWaitMcpTools``WaitForTaskChange(taskIds, timeoutSeconds = 60)`: blocks until any given task leaves `Queued`/`Running`, or times out; returns immediately for a task already outside `Queued`/`Running` (unknown ids reported as status `"NotFound"`, also immediate). Implemented as an async DB poll (short-lived `DbContext` per check, 500ms delay between checks, no held connection or busy loop) rather than hooking `HubBroadcaster` — kept deliberately isolated so it can't regress the existing broadcast callers. `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (170s), comfortably under the list handler's `MCP_TOOL_TIMEOUT` (200s, see `InteractiveLaunchSpecService`) so the tool reports `timedOut: true` instead of racing the client's own abort. Replaces the list handler's old "sleep + poll get_task in a loop" Phase 3 instruction (`PromptFiles.MergeHelperDefault`).
- `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
- `ExternalMcpService` also exposes two daily-prep tools:
- `GetDailyPrepCandidates` — returns Idle, non-blocked tasks in a git repo NOT excluded by `AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter` helper (same file).
- `SetMyDay` — sets a task's `IsMyDay` (+ optional `SortOrder`); server-side cap-guard rejects turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
## Daily Prep (Prime Claude)
- **PrimeScheduler** (hosted `BackgroundService`) computes the next due time from the `prime_schedules` table and at that time calls `IPrimeRunner.FireAsync`. A manual run arrives via `WorkerHub.RunDailyPrepNow`. A `SemaphoreSlim` single-flight gate **in `PrimeRunner`** prevents overlapping runs (returns "already running"); both scheduled and manual runs go through it.
- **PrimeRunner** builds a fixed prompt via `DailyPrepPrompt.BuildPrompt`, parameterized by `AppSettings.DailyPrepMaxTasks` and today's date, then invokes:
```
claude -p --output-format stream-json --verbose --permission-mode acceptEdits --max-turns 30
--allowedTools mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day
```
It relies on the globally-registered `claudedo` MCP (installer's `RegisterMcpStep`) — no separate `--mcp-config`. This replaced the old warm-up "ping".
- Each stdout line is streamed to the UI via `IPrimeBroadcaster.PrepLineAsync` AND written to `DailyPrepPrompt.LogPath()` = `<appdata>/logs/daily-prep.log` (truncated at the start of each run → last run only). `PrepStarted`/`PrepFinished` events bracket the run.
- Agentic behaviour: Claude calls `get_daily_prep_candidates`, picks an effort-aware subset capped at `DailyPrepMaxTasks`, and marks them via `set_my_day` (which broadcasts `TaskUpdated` so the UI updates live).
## Usage Monitor & Gate
Source: `GET https://api.anthropic.com/api/oauth/usage`, an **undocumented** Anthropic API,
authenticated with the Bearer access token Claude Code itself keeps fresh at
`~/.claude/.credentials.json` — ClaudeDo reads that token but never refreshes it and never
logs it. `UsageMonitorService` polls on `usage_poll_interval_seconds` (default 60s, one poll
at startup too) and broadcasts `HubBroadcaster.UsageUpdated` after every tick.
The gate (`IUsageGate`, thresholds `usage_gate_five_hour_pct`/`usage_gate_seven_day_pct`)
pauses **only** the queue's slot-fill loop (new tasks don't start) once `five_hour >=
usage_gate_five_hour_pct` or `seven_day >= usage_gate_seven_day_pct`; already-running runs,
and `RunNow`/`ContinueTask`/interactive ConPTY/planning sessions/daily-prep all bypass the
queue and are unaffected. **Fail-open**: no snapshot yet, a failed last poll, or an
app-settings read error all resolve to not-blocked, so an outage of the (undocumented, can
change without notice) usage endpoint never blocks work. There's no persistent pause state —
recovery is just the queue's 30s backstop timer re-evaluating the gate on its own once usage
drops back under the threshold. See `Usage/` in the folder layout above for the component
breakdown.
Ahead of that hard gate, `UsageThrottle` steps the queue's effective parallelism down in two
stages (thresholds `usage_throttle_soft_pct`/`usage_throttle_hard_pct`, defaults 50/65):
whichever of 5h/7d is more utilized decides the stage — below soft = full configured
`max_parallel_executions`, at/above soft = capped to 2 slots, at/above hard = capped to 1,
at/above either gate threshold = 0 (the pre-existing hard pause, unchanged). Only *new* slot
fills are affected; a run already occupying a slot when the stage tightens keeps running to
completion. Same fail-open policy as the gate — no snapshot yet means no throttling, full
configured parallelism. The effective stage (configured vs. effective slots, decisive bucket)
rides along on `UsageSnapshotDto`/`GetUsageSnapshot` for UI display (`UsagePillViewModel`
tooltip, `UsageMonitorModalViewModel`'s throttle band) — it does not change what the gate
itself gates on.
- **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).
## Status Model
`TaskEntity` carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated.
`TaskEntity` carries orthogonal fields — lifecycle, planning hierarchy, and chain blocking are
not conflated.
| Field | Values | Meaning |
|---|---|---|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
| `IsManual` | bool | Reminder only the user can do. `TaskStateService.EnqueueAsync`/`StartRunningAsync` refuse it, the queue picker skips it, and `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. |
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
Allowed transitions (enforced by `TaskStateService`):
```
Idle → Queued | Running (RunNow) | Cancelled (external update_task_status only, allowFromIdle: true)
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap)
Running → WaitingForReview (standalone success, no children)
| WaitingForChildren (parent with pending children)
| Done (planning/improvement child success) | Failed | Cancelled
@@ -119,134 +78,106 @@ Cancelled → Idle | Queued
```
**Unified parent model.** Every parent — planning *or* improvement — flows
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
`TaskStateService.TryAdvanceParentAsync` (surfaces any `WaitingForChildren` parent for
review once all children are terminal; failed/cancelled children are annotated on the
result, not wedged). A planning parent enters `WaitingForChildren` at
`FinalizePlanningAsync` (or `WaitingForReview` directly if it has no children); an
improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned
children. Planning/improvement **children** still go straight to `Done` (no individual
review) — only the parent is reviewed.
`… → WaitingForChildren → WaitingForReview → Done` via the single `TryAdvanceParentAsync`.
Planning/improvement **children** go straight to `Done`; only the parent is reviewed.
**Approve = merge the whole unit.** `ApproveReview`/`review_task` approve, for a task
that has children, drives `PlanningMergeOrchestrator` (merges the parent worktree if
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
approve is the single review+merge action.
**Post-merge verify gate.** A list can set `ListConfigEntity.VerifyCommand` (List Settings
modal → Verification). Null/blank (the default) = no gate, behavior is bit-identical to
before this existed. When set, `TaskMergeService` runs it via `VerifyCommandRunner`
(`cmd.exe /c <command>`, 10-minute fixed timeout, output tail-captured) in `list.WorkingDir`
right after a successful `MergeNoFfAsync`/`ContinueMergeAsync` and worktree cleanup, but
*before* the task is allowed to reach `Done`. Exit 0 → unchanged flow (worktree marked
`Merged`, task `Done` if it was `WaitingForReview`). Non-zero exit or a timeout → the git
merge is deliberately left in place (no auto-revert — that's a separate, unbuilt feature),
the worktree is still marked `Merged` (it's already gone from disk when `removeWorktree`
was requested), but the task stays out of `Done` and `MergeResult.Status` comes back
`TaskMergeService.StatusVerifyFailed` (`"verify_failed"`) with an output excerpt in
`ErrorMessage` — this flows through `MergeResultDto` (hub) and `ReviewTaskResult`
(`review_task` MCP tool) unchanged, since both already treat any non-`blocked`/`conflict`
status generically. A process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync`/`ContinueMergeAsync` (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build. Review transitions live in `TaskStateService`
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
**Approve = merge the whole unit.** `ApproveReview` / `review_task` approve is the single
review+merge action — there is no separate "Merge all". Details, plus the post-merge verify
gate and the conflict resolver → [review-merge](../../docs/explore-notes/review-merge.md).
## Planning Flow
`PlanningSessionManager.FinalizeAsync` is the single path:
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (`BlockOn`s child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`, which sets every non-terminal child `Queued` and re-applies the chain.
1. `_state.FinalizePlanningAsync(parent)` flips `PlanningPhase` to `Finalized` and `Status` to `WaitingForChildren` (or `WaitingForReview` if childless).
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`.
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not** advance the parent — the parent stays in `WaitingForChildren` until every child is terminal. The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children need attention" band) so the roadblock is visible without forcing a transition.
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED`) does **not** advance the
parent — it stays in `WaitingForChildren` until every child is terminal, with blocked children
surfaced on the parent's Session tab instead.
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
`TaskRepository.FinalizePlanningAsync` no longer exists; the `Mark*Async` repository helpers are
`internal` — only `TaskStateService` calls them.
## Task Execution Pipeline
`TaskRunner` orchestrates:
1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path)
2. Create worktree (if `WorkingDir` set) or sandbox directory
3. Mark task "running", broadcast `TaskStarted`
4. Resolve the effective model (task → list → `AppSettings.DefaultModel`), then take its `ModelPresets` row via `ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns)`: the model string is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`, not just the bare `sonnet`/`opus`/`haiku`/`fable` aliases) still hits its alias's preset row; only a truly unrecognized model falls back to a synthesized row using `AppSettings.DefaultMaxTurns` (never a hardcoded number, and never throws — an unknown model must not block a run). The preset supplies `--effort` and the **global** max-turns default (task/list `MaxTurns` overrides still win). The task/list/global-resolved value is then hard-clamped to `AppSettings.MaxTurnsCeiling` (default 80) via `TaskRunner.ResolveMaxTurns` — a task or list override above the ceiling still starts, just capped, and a Warn is logged with the task id, requested, and effective value. Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt
5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`)
6. On success: auto-commit changes (worktree only), store run record, mark "done"
7. On failure: retry once if session ID available (`--resume`), then mark "failed"
`TaskRunner` orchestrates: load task + config → worktree or sandbox → mark running → build args →
spawn `ClaudeProcess` → stream NDJSON through `StreamAnalyzer` → on success auto-commit + store
run record, on failure retry once via `--resume` then fail.
Full flow, invariants, and model/effort/max-turns resolution (including the low-preset turn trap)
→ [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
## Key Components
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history.
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
- **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
- **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree.
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.todo-app/agents/*.md`; list/refresh via SignalR
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
## Execution History
Each CLI invocation is recorded in `task_runs` via `TaskRunRepository`. ⚠️ Token fields come from
the **session transcript**, not the stream-json result event, as a per-run delta →
[usage-monitoring](../../docs/explore-notes/usage-monitoring.md). `TaskRunner.ContinueAsync` sends
a follow-up prompt to an existing session via `--resume <session_id>`.
Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`:
- Fields: `session_id`, turn count, `result` text, structured output JSON, and the four raw token
fields (`tokens_in`/`tokens_out`/`cache_read_tokens`/`cache_write_tokens`) — **not** read from the
stream-json "result" event's `usage.input_tokens` (that's only the uncached remainder of one API
call and undercounts the real prompt size by orders of magnitude once caching kicks in). Instead
`TaskRunner.ApplyUsageAsync` reads `ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` —
the session transcript's cumulative totals across every assistant message — and stores the
**delta** against prior `task_runs` rows sharing the same `session_id`, so a `--resume`'d run
doesn't double-count the turns already billed to an earlier run. A missing/unreadable transcript
leaves all four fields `null`; it never fails the run.
- Enables auto-retry on failure (resume last session) and multi-turn follow-up via `ContinueAsync`
## Daily Prep (Prime Claude)
## Multi-Turn / Continue
**PrimeScheduler** (`BackgroundService`) computes the next due time from `prime_schedules` and
calls `IPrimeRunner.FireAsync`; manual runs arrive via `WorkerHub.RunDailyPrepNow`. The
`SemaphoreSlim` single-flight gate lives **in `PrimeRunner`**, not the scheduler, so both paths go
through it (returns "already running").
`TaskRunner.ContinueAsync` sends a follow-up prompt to an existing Claude session using `--resume <session_id>` with the stored session ID from the last run.
**PrimeRunner** builds a fixed prompt via `DailyPrepPrompt.BuildPrompt` (parameterized by
`AppSettings.DailyPrepMaxTasks` + today's date), then runs `claude -p` with
`--permission-mode acceptEdits --max-turns 30` and `--allowedTools
mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day`. It relies on the
globally-registered `claudedo` MCP (installer's `RegisterMcpStep`) — no separate `--mcp-config`.
Claude then calls `get_daily_prep_candidates`, picks an effort-aware subset capped at
`DailyPrepMaxTasks`, and marks them via `set_my_day`. Each stdout line goes to the UI via
`IPrimeBroadcaster.PrepLineAsync` **and** to `DailyPrepPrompt.LogPath()` =
`<appdata>/logs/daily-prep.log`, which is **truncated at the start of each run** (last run only).
`PrepStarted`/`PrepFinished` bracket the run.
## SignalR Hub
**WorkerHub** methods, grouped:
`WorkerHub` is the canonical method list — grep it rather than trusting a doc inventory.
Groups: execution · review/merge · conflict resolver · planning sessions · interactive ConPTY
launch specs · worktrees · agents/settings/lists · reports/notes/prep · diagnostics · usage.
`IWorkerClient` in `ClaudeDo.Ui` mirrors it.
- Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask`
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
- Interactive sessions (embedded ConPTY, UI process): `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`, `CreateMergeHelperTask` (creates the ClaudeDo task that owns a list-handler run — `Idle`/`IsManual=true`, `HandlerBaseCommit` stamped to the list repo's current HEAD via `InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` — called by the UI before it opens the task-based ConPTY tile), `SubmitTaskForReview` (branches on whether the task has a `WorktreeEntity`: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stamps `HandlerHeadCommit` to the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). Every ConPTY spec that `InteractiveLaunchSpecService` builds passes `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc) — it leads the args except for a fresh task session with a brief, where `--add-dir <sessionDir>` must come first so `--effort` (a single-value flag) can sit directly before the positional kickoff (see below). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary. **No ConPTY path ever passes task free-text (title/description/brief) as a CLI argument** — every one of them (task session, planning start/resume, list handler) writes it to a file first and hands claude a single-line kickoff pointing at that file, exposed via `--add-dir`. Reason: the ConPTY host flattens `Args` into one command line to spawn the process, and claude re-splits that line on whitespace, so any token starting with `-` (e.g. `->`, `--abort`) in real task text would be misread as an unknown option — and a raw multi-line positional prompt truncates at its first newline regardless. A fresh task session's brief lives at `~/.todo-app/task-sessions/<taskId>/brief.md` (`InteractiveLaunchSpecService.BuildFreshTaskArgsAsync`); a task with neither title nor description skips the file and the positional arg entirely.
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
- Diagnostics: `GetRecentLogs` (last 30 min of buffered log records, all levels, for the Log Visualizer overlay)
- Usage: `GetUsageSnapshot() -> UsageSnapshotDto` (built by `UsageSnapshotBuilder` from `UsageState` + `IUsageGate` + `AppSettings` gate thresholds; percentages/limits/`FetchedAtUtc` null and `IsStale=true` when no snapshot has landed yet; `IsStale` also trips on a failed last poll or a snapshot older than 3× `usage_poll_interval_seconds`), `GetModelUsage(from, to) -> IReadOnlyList<ModelUsageRowDto>` (thin wrapper over `ITranscriptUsageReader.ReadAsync`), `GetTaskUsage(from, to) -> IReadOnlyList<TaskUsageRowDto>` (top consumers from `task_runs` joined to task/list, grouped per task — `Runs`/summed `TokensIn`/`TokensOut` (null token columns count as 0, never dropped), `Model` from that task's most recent run — sorted by total tokens descending, capped at 100)
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`,
`TaskUpdated`, `RunCreated`, `ListUpdated`, `WorkerLog`, `PrimeFired`, `PrepStarted`, `PrepLine`,
`PrepFinished`, `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`,
`PlanningMergeAborted`, `PlanningCompleted`, `RefineStarted`, `RefineFinished`, `UsageUpdated`.
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `WorkerLog`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`, `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`, `RefineStarted`, `RefineFinished`, `UsageUpdated` (carries the same `UsageSnapshotDto` as `GetUsageSnapshot`; `UsageMonitorService` fires it after every poll cycle, success or failure, via the shared `UsageSnapshotBuilder`)
`WorkerLog` carries two sources: the hand-curated business events (`_broadcaster.WorkerLog(...)` in TaskRunner/TaskMergeService/TaskResetService) **and** every Serilog **Warn/Error** event, re-broadcast by `BroadcastLogSink` (deduped within a 120 s per-message window; SignalR plumbing source-contexts filtered to avoid feedback loops). The sink also buffers **all** levels into `LogRingBuffer` for `GetRecentLogs`.
`WorkerLog` carries two sources: hand-curated business events (`_broadcaster.WorkerLog(...)` in
TaskRunner/TaskMergeService/TaskResetService) **and** every Serilog Warn/Error, re-broadcast by
`BroadcastLogSink` (deduped within a 120 s per-message window; SignalR plumbing source-contexts
filtered to avoid feedback loops). The sink also buffers **all** levels into `LogRingBuffer` for
`GetRecentLogs`.
## Config
Loaded from `~/.todo-app/worker.config.json`:
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` ("sibling" | "central"), `central_worktree_root`
- `queue_backstop_interval_ms` (default 30000)
- `signalr_port` (default 47821)
- `claude_bin` (path to claude CLI)
- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled):
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
- `poll_interval_seconds` (int, default 60)
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` — used by `ZitadelAuthProvider` (OIDC discovery + refresh-token flow)
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
- `usage_poll_interval_seconds` (default 60, clamped to a minimum of 15 on load) — poll interval for `UsageMonitorService`
`~/.todo-app/worker.config.json`:
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order). Planning's `CreateChildTask` additionally accepts an optional `maxTurns` (positive int; `0`/negative rejected with `ArgumentException`, null = inherit list/global default) so the planner can raise the turn budget for a subtask it knows will run long; `SuggestImprovement`/`AddTask` do not expose it.
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root`
- `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer
- `signalr_port` (47821), `claude_bin`
- `usage_poll_interval_seconds` (60, clamped to min 15 on load)
- `online_inbox``enabled` (false by default; when false the entire `Online/` stack is not registered), `api_base_url` (must be HTTPS or loopback, validated at startup), `poll_interval_seconds` (60), `zitadel.authority`/`client_id`/`scopes`. The refresh token is **not** in this file — DPAPI-encrypted at `~/.todo-app/online-inbox.token`.
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
`agent_path`, `max_turns`, `session_skills`, `verify_command`; tasks override each individually.
## Notes
- The worker runs standalone — start it separately from the UI
- Only listens on loopback (127.0.0.1)
- ClaudeProcess uses `--permission-mode auto` by default; legacy "bypassPermissions" settings are mapped to `auto` at dispatch time. `acceptEdits`, `plan`, and `default` pass through unchanged.
- Worktree branches follow `claudedo/{id}` naming convention
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
- `--permission-mode auto` by default; legacy `bypassPermissions` settings map to `auto` at dispatch time. `acceptEdits`, `plan`, `default` pass through unchanged.
- Worktree branches follow `claudedo/{id}`.