Files
ClaudeDo/src/ClaudeDo.Worker/CLAUDE.md
T
mika kuns 6c8ec245b3 feat(worker): dependsOn via MCP + honest staleness signal in merge preview
Adds a user/MCP-declared task dependency (DependsOnTaskId) distinct from the
planning chain's internal BlockedByTaskId: add_task/update_task can set it,
the queue picker skips a Queued task until the dependency reaches Done, a
Failed/Cancelled dependency leaves the dependent blocked instead of starving
silently, and setting a link rejects self-reference/unknown-id/cycles.
get_task/list_tasks/batch_get_tasks now report blocked/blockedReason, and
wait_for_task_change reports "Blocked" immediately instead of running out its
timeout on a task the picker will never claim.

preview_merge/preview_merge_set gain staleFiles: files a branch touches that
the target branch also changed since the branch's fork point, a more honest
staleness signal than `behind` alone.
2026-08-10 14:18:55 +02:00

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 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. Two hard conventions (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare Task/a nullable payload. Tool-description style (not test-enforced) is documented in External/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.
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:

  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
  • 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):

  • TaskStarted
  • TaskFinished
  • TaskMessage
  • WorktreeUpdated
  • TaskUpdated
  • ListUpdated
  • WorkerLog
  • PrimeFired
  • PrepStarted
  • PrepLine
  • PrepFinished
  • PlanningMergeStarted
  • PlanningSubtaskMerged
  • PlanningMergeConflict
  • PlanningMergeAborted
  • 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, 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 auto by default; legacy bypassPermissions settings map to auto at dispatch time. acceptEdits, plan, default pass through unchanged.
  • Worktree branches follow claudedo/{id}.