Merge/verify phases now broadcast over a generic (opKey, phase, current, total) Hub event instead of a merge-specific one, so future producers (worktree cleanup, startup recovery, planning integration) can reuse it. IWorkerClient.MergeProgressEvent stays as a thin forwarder for existing consumers (elapsed seconds riding in the generic "current" slot).
18 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. Task-ID resolution: every tool parameter that accepts a task id (taskId,parentId,taskIds, etc.) is wired throughTaskIdResolver, 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}) — theNumberis a display alias, never identity. Every task-returning tool stamps the task'sNumberin 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;ExternalMcpToolSchemaTestsguards this. Other conventions (not test-enforced): no tool returns bareTask/a nullable payload directly (write tools return a confirmation record likeRunTaskNowResult; read tools use an explicitFound/Availableflag); tool descriptions follow rules documented inExternal/McpToolDocs.cs. 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;BuildMergeis the merge-commit variant ({commitType}(slug): merge title+ trailer). Every merge caller passes a blank commit message on purpose —TaskMergeServicefills inBuildMergefrom 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. 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):
TaskStartedTaskFinishedTaskMessageWorktreeUpdatedTaskUpdatedListUpdatedWorkerLogPrimeFiredPrepStartedPrepLinePrepFinishedOperationProgress(generic(opKey, phase, current, total)channel; merge phases are its first producer —IWorkerClient.MergeProgressEventon the Ui side is a thin forwarder over it for existing consumers, elapsed seconds riding in thecurrentslot)PlanningMergeStartedPlanningSubtaskMergedPlanningMergeConflictPlanningMergeAbortedPlanningCompletedRefineStartedRefineFinishedUsageUpdated
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; 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 autoby default; legacybypassPermissionssettings map toautoat dispatch time.acceptEdits,plan,defaultpass 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.