14 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)
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. All transitions return aTransitionResult(no exceptions on invalid moves). Wakes the queue and broadcastsTaskUpdatedautomatically; advances the planning chain on child terminal transitions. - IQueueWaker / IQueuePicker / QueueService — waker is a singleton
SemaphoreSlim; picker performs the atomicQueued → Runningclaim filtered byBlockedByTaskId IS NULL,is_manual = 0and schedule; 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. 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. |
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):
TaskStartedTaskFinishedTaskMessageWorktreeUpdatedTaskUpdatedRunCreatedListUpdatedWorkerLogPrimeFiredPrepStartedPrepLinePrepFinishedPlanningMergeStartedPlanningSubtaskMergedPlanningMergeConflictPlanningMergeAbortedPlanningCompletedRefineStartedRefineFinishedUsageUpdated
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_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}.