Files
ClaudeDo/src/ClaudeDo.Worker/CLAUDE.md
T
mika kuns 69fcceb2ed feat(worker): run both MCP servers stateless
Set Stateless = true on the internal (/mcp on the SignalR port) and external
MCP HTTP transports. Two effects:

- No Mcp-Session-Id, so a worker restart can no longer 404 a session that
  outlives it -- ConPTY tiles in the UI process and externally registered
  claude sessions keep working across a restart.
- A 2026-07-28 client is no longer refused back to the initialize handshake.
  A stateful server rejects that revision on purpose (see the SDK's
  StreamableHttpHandler.s_sessionSupportingProtocolVersions), so the new
  per-request protocol path was unreachable regardless of SDK version.

Nothing here used the stateful-only features (sampling, elicitation, resource
subscriptions, unsolicited notifications). In-tool progress notifications ride
the POST's own response stream and are unaffected -- which matters, since ~20
tools use them to hold off the client's 300s idle abort. All three consumers
(TaskRunner, PlanningSessionManager, the installer's RegisterMcpStep) already
register type: "http", so dropping the legacy SSE endpoint breaks nothing.

Verified against the running worker across two restarts: tools/list returns
57 tools over a bare POST with no initialize and no session id, and a full
2026-07-28 tools/call round-trip returns real data.
2026-08-26 11:05:31 +02:00

21 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/        — "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.
  • 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.

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.

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.

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 purposeTaskMergeService 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 ~/.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>.

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. 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

~/.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_inboxenabled (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}.
  • 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.