16 KiB
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 ·
usage-monitoring ·
external-mcp ·
review-merge ·
conpty-sessions
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 to127.0.0.1:47821 - TaskStateService — the only component that writes
Status,PlanningPhase,BlockedByTaskId,DependsOnTaskId. All transitions return aTransitionResult(no exceptions on invalid moves). Wakes the queue and broadcastsTaskUpdatedautomatically; advances the planning chain on child terminal transitions.SetDependsOnAsyncrejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's ownDependsOnTaskIdchain). - IQueueWaker / IQueuePicker / QueueService — waker is a singleton
SemaphoreSlim; picker performs the atomicQueued → Runningclaim filtered byBlockedByTaskId IS NULL,is_manual = 0, schedule, and (DependsOnTaskId IS NULLOR the dependency'sStatus = 'done'); QueueService is a thinBackgroundServicelooping on the waker, dispatching viaTaskRunner. Per tick it also applies the usage throttle and gate → usage-monitoring. - RunCancellationRegistry — taskId → running-run CTS. Lets
TaskStateService.CancelAsynckill a cancelled task's process without a DI cycle. - OverrideSlotService — owns
RunNow/ContinueTask; goes throughTaskStateService.StartRunningAsync(caller-driven, serialized by slot lock). - StaleTaskRecovery — startup-only; calls
TaskStateService.RecoverStaleRunningAsyncto flip orphanedRunningrows toFailed. - 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 bareTask/a nullable payload. Tool-description style (not test-enforced) is documented inExternal/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.
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.
Planning Flow
PlanningSessionManager.FinalizeAsync is the single path:
_state.FinalizePlanningAsync(parent)flipsPlanningPhasetoFinalizedandStatustoWaitingForChildren(orWaitingForReviewif childless).PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)establishes the blocked-by chain (child[i] → child[i-1]) but leaves childrenIdle— finalize never auto-queues. Queueing is a deliberate user action:QueuePlanAsync(hubQueuePlanningSubtasksAsync, the "Queue plan" button) callsSetupChainAsync(parent, enqueue: true).- 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.
Key Components
- ClaudeCliPreflight — startup check that
claude --versionruns; resolves the binary viaExecutableResolverfirst and returns a clear "not found on PATH" result instead of a rawProcess.Startexception when it doesn't. (Not yet onmain— 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.ClaudeBinviaExecutableResolver(ClaudeDo.Data, shared withClaudeCliPreflightand the Installer's checks) before spawning — a.cmd/.batshim (e.g. an npm-installedclaude) is launched throughcmd.exe /csinceUseShellExecute = falsecan't exec a shim directly; a resolved.exestarts exactly as before. Throws if nothing resolves. (Not yet onmain— seeEnvironment ChecksinClaudeDo.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. 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):
TaskStartedTaskFinishedTaskMessageWorktreeUpdatedTaskUpdatedListUpdatedWorkerLogPrimeFiredPrepStartedPrepLinePrepFinishedPlanningMergeStartedPlanningSubtaskMergedPlanningMergeConflictPlanningMergeAbortedPlanningCompletedRefineStartedRefineFinishedUsageUpdated
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_findingtool registered on both MCP surfaces:External/FindingsMcpTools(needs alistargument; refuses rather than guessing when several lists qualify) andRunner/TaskRunFindingsMcpTools(resolves the list fromTaskRunMcpContextAccessor). Same slug overwrites. Both go throughFindingsStoreLocator, which always resolves to the list'sWorkingDir— the main checkout, never a worktree copy, or parallel runs would collide onINDEX.md. - Read with plain
Read; there is deliberately no read tool.TaskRunner.BuildFindingsPointeradds one system-prompt layer naming the index, and only when the index file exists. ListEntity.FindingsTrackeddecides whether the folder is committed. When false,FindingsStorewrites/.claudedo/to.git/info/excludeviaGitExcludeWriter— per-clone, no tracked file touched.INDEX.mdis read by every run, so it must stay short:FindingsStore.WarnThreshold(80) flipsnearCapacityin the tool result as a prune signal.
Config
~/.todo-app/worker.config.json:
db_path,sandbox_root,log_rootworktree_root_strategy(sibling|central),central_worktree_rootqueue_backstop_interval_ms(30000) — also the gate/throttle recovery timersignalr_port(47821),claude_binusage_poll_interval_seconds(60, clamped to min 15 on load)online_inbox—enabled(false by default; when false the entireOnline/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 autoby default; legacybypassPermissionssettings map toautoat dispatch time.acceptEdits,plan,defaultpass through unchanged.- Worktree branches follow
claudedo/{id}.