# 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) 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`. 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; 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 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. | | `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` - **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` - `RunCreated` - `ListUpdated` - `WorkerLog` - `PrimeFired` - `PrepStarted` - `PrepLine` - `PrepFinished` - `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`. ## 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`, `verify_command`; tasks override each individually. ## 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}`.