264 lines
21 KiB
Markdown
264 lines
21 KiB
Markdown
# 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/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
|
|
DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal
|
|
Online/ — optional Online Inbox sync (off by default; zero network when disabled)
|
|
Usage/ — OAuth usage monitor, gate, throttle, per-session token reader;
|
|
TokenTracker/ = the external analytics backend (cost + per-model/per-task breakdown)
|
|
```
|
|
|
|
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 `#<number>` 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
|
|
|
|
any except Running → Done (manual done toggle, SetTaskDone)
|
|
```
|
|
|
|
The manual done toggle is the one cross-cutting transition — it ticks a task off from **any**
|
|
status except `Running`, skipping the merge on purpose (a worktree-less list-handler task has
|
|
nothing to merge; a worktree task's branch just stays Active for the worktrees overview).
|
|
|
|
**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 `~/.claudeDo/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>`.
|
|
|
|
## Prime Claude
|
|
|
|
Every schedule carries a `PrimeActionKind` (`Ping` — the default for new schedules — `FillMyDay`,
|
|
or `Custom`). `PrimeRunner.FireAsync` holds one `SemaphoreSlim` gate for **all** kinds, then
|
|
branches:
|
|
|
|
| Kind | Prompt | Args | Log + `Prep*` events |
|
|
|---|---|---|---|
|
|
| `Ping` | `PrimePrompts.PingPrompt`, fixed | `--max-turns 1 --strict-mcp-config` (no MCP server loads at all) | none |
|
|
| `FillMyDay` | `DailyPrepPrompt.BuildPrompt` (+ the schedule's prompt as an *addition*) | the two MyDay MCP tools | `daily-prep.log`, truncated per run, + `PrepStarted`/`PrepLine`/`PrepFinished` |
|
|
| `Custom` | the schedule's prompt *verbatim* | `--allowedTools mcp__claudedo` — no Read/Write/Edit/Bash | none |
|
|
|
|
Ping and Custom deliberately write **no** log and raise **no** `Prep*` events: the prep log
|
|
belongs to the MyDay selection and would otherwise be overwritten with ping noise. All three
|
|
kinds still broadcast `PrimeFired` and update `last_run_at`.
|
|
|
|
`WorkerHub.RunDailyPrepNow` (the "Plan day" button) pins its synthetic schedule to `FillMyDay` —
|
|
the button means "fill my day" no matter how the schedules are configured.
|
|
`WorkerHub.UpsertPrimeSchedule` runs `PrimeScheduleValidation.Validate` first, so a `Custom`
|
|
schedule can never be saved without a prompt.
|
|
|
|
**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. Its wire
|
|
DTOs live in `ClaudeDo.Data/Wire.cs` (shared with `IWorkerClient` via a `<Using>` item), **not** in
|
|
`WorkerHub.cs` — don't re-declare one locally, that's how `MergePreviewDto` lost its verify fields
|
|
on the client side.
|
|
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` (`planningTaskId, reason` — every call site passes a human-readable
|
|
reason now, from a real merge failure's `ErrorMessage` to a plain "Merge aborted."; the UI
|
|
flashes it via `FlashFooterError`)
|
|
- `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
|
|
|
|
`~/.claudeDo/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 `~/.claudeDo/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}`.
|
|
- **Both MCP servers run `Stateless = true`** (`Program.cs`). No `Mcp-Session-Id`, so a worker restart can't 404 a session that outlives it, and a 2026-07-28 client isn't refused back to the initialize handshake. The trade: **no** sampling, elicitation, resource subscriptions, unsolicited notifications, or legacy SSE endpoint — don't reach for those. In-tool progress notifications ride the POST's own response stream and work in both modes. All three consumers (`TaskRunner`, `PlanningSessionManager`, the installer's `RegisterMcpStep`) already use `type: "http"`.
|
|
- **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. `Lifecycle/ProgressReporter` is the one implementation (elapsed-time reporting via `RunAsync`, per-item `i/n` via `ReportItem`) — thread an `IProgress<ProgressNotificationValue>? progress = null` parameter through instead of writing another polling loop.
|