# 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`. **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

`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`
- **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 <session_id>`.

## 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()` =
`<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` 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`
- `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}`.
