# ClaudeDo.Worker 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, OverrideSlotService, RunCancellationRegistry Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner, ClaudeCliPreflight (resolves via ExecutableResolver, see Key Components below), OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery, PromptFileRecovery (last four = startup sweeps) Worktrees/ — WorktreeMaintenanceService Agents/ — AgentFileService, DefaultAgentSeeder 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) Findings/ — FindingsStore + FindingsStoreLocator: the per-project `.claudedo/` trap store Git/ — GitExcludeWriter (`.git/info/exclude`) + GitHead, shared by skill seeding and findings Config/ — WorkerConfig Hub/ — WorkerHub, HubBroadcaster 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 (`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** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, `DependsOnTaskId`. 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. `SetDependsOnAsync` rejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's own `DependsOnTaskId` chain). - **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and (`DependsOnTaskId IS NULL` OR the dependency's `Status = 'done'`); 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; 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`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Branch names and worktree paths continue to use the GUID (`claudedo/{id}`) — the `Number` is a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md). ## Status Model `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 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 a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. | | `DependsOnTaskId` | nullable FK | User/MCP-declared predecessor (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker only skips a queued row while the dependency's `Status` isn't `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `blocked`/`blockedReason`; `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. | | `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) Running → WaitingForReview (standalone success, no children) | WaitingForChildren (parent with pending children) | Done (planning/improvement child success) | Failed | Cancelled WaitingForChildren → WaitingForReview (all children terminal) | Cancelled WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled Done → Idle (re-run) Failed → Idle | Queued Cancelled → Idle | Queued ``` **Unified parent model.** Every parent — planning *or* improvement — flows `… → 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 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 `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`) 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. ## Task Execution Pipeline `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 - **ClaudeCliPreflight** — startup check that `claude --version` runs; resolves the binary via `ExecutableResolver` first and returns a clear "not found on PATH" result instead of a raw `Process.Start` exception when it doesn't. *(Not yet on `main` — see below.)* - **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. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)* - **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`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal. - **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 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 `. ## Daily Prep (Prime Claude) **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"). **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()` = `/logs/daily-prep.log`, which is **truncated at the start of each run** (last run only). `PrepStarted`/`PrepFinished` bracket the run. ## SignalR Hub `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. **HubBroadcaster** events (one per line so parallel features don't collide on the same line): - `TaskStarted` - `TaskFinished` - `TaskMessage` - `WorktreeUpdated` - `TaskUpdated` - `ListUpdated` - `WorkerLog` - `PrimeFired` - `PrepStarted` - `PrepLine` - `PrepFinished` - `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its first producer — `IWorkerClient.MergeProgressEvent` on the Ui side is a thin forwarder over it for existing consumers, elapsed seconds riding in the `current` slot) - `PlanningMergeStarted` - `PlanningSubtaskMerged` - `PlanningMergeConflict` - `PlanningMergeAborted` - `PlanningCompleted` - `RefineStarted` - `RefineFinished` - `UsageUpdated` `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`. ## Findings store Each list's working directory gets a `.claudedo/` folder: one markdown file per finding under `traps/`, plus an `INDEX.md` that `FindingsStore` **rebuilds from disk on every save** (so findings deleted or renamed by hand self-heal). Findings are traps and invariants only — lasting, non-obvious, behaviour-changing. A fixed bug is git history, not a finding. - **Written** via one `save_finding` tool registered on *both* MCP surfaces: `External/FindingsMcpTools` (needs a `list` argument; refuses rather than guessing when several lists qualify) and `Runner/TaskRunFindingsMcpTools` (resolves the list from `TaskRunMcpContextAccessor`). Same slug overwrites. Both go through `FindingsStoreLocator`, which always resolves to the list's `WorkingDir` — the **main checkout**, never a worktree copy, or parallel runs would collide on `INDEX.md`. - **Read** with plain `Read`; there is deliberately no read tool. `TaskRunner.BuildFindingsPointer` adds one system-prompt layer naming the index, and only when the index file exists. - `ListEntity.FindingsTracked` decides whether the folder is committed. When false, `FindingsStore` writes `/.claudedo/` to `.git/info/exclude` via `GitExcludeWriter` — per-clone, no tracked file touched. - `INDEX.md` is read by every run, so it must stay short: `FindingsStore.WarnThreshold` (80) flips `nearCapacity` in the tool result as a prune signal. ## Config `~/.todo-app/worker.config.json`: - `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`; tasks override each individually. `verify_command` is list-only — there is no task-level override — and is written via `set_list_config`'s `verifyCommand` parameter (`ConfigMcpTools`), the only writer besides the UI's list config editor. ## Notes - 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}`. - **An MCP tool that can run longer than ~5s reports progress.** Staying silent lets the MCP client abort after 300s idle while the worker keeps working — the caller sees an abort even though the operation is still running.