36 KiB
ClaudeDo.Worker
ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.
Folder Layout
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists), PromptFileRecovery (startup sweep: `PromptFiles.ReconcileStaleDefaults()` drops any prompt override that only matched a now-superseded default and was never actually edited, `QuarantineOrphans()` moves *.md files under `prompts/` with no matching `PromptKind` into `prompts/_orphans/`)
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning sessions
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
External/ — ExternalMcpService + sibling tool classes
Config/ — WorkerConfig
Hub/ — WorkerHub, HubBroadcaster
Logging/ — LogRingBuffer (30-min in-memory log window) + BroadcastLogSink (Serilog sink → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache; `<synthetic>`-model lines are skipped everywhere, not real API calls; also exposes `ReadSessionTotalsAsync(sessionId)` — cumulative raw input/output/cache-read/cache-write totals for one session, located by `{sessionId}.jsonl`, used by `TaskRunner` to populate `task_runs`' per-run token fields), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0–100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked), UsageThrottle (pure static `EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, gateFiveHourPct, gateSevenDayPct)` — stages parallelism down ahead of the hard gate: whichever bucket is more utilized decides the stage, `0` for a threshold disables that stage, `>= softPct` caps at 2 slots, `>= hardPct` caps at 1, `>= either gate threshold` returns 0 — same 0 as `UsageGate`'s hard block, kept in sync since both read the same gate thresholds); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate)
Interfaces (e.g. 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 — 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 thinBackgroundServicethat loops on the waker and dispatches viaTaskRunner. On each loop tick,GetEffectiveMaxParallelAsyncreadsAppSettings.MaxParallelExecutionsand steps it down viaUsageThrottle.EffectiveSlotsagainst the currentUsageStatesnapshot (a missing/failed snapshot fails open to the configured value — never throttles on a broken poll); a stage change (not every tick) logs once via the standard logger. Separately, it also asksIUsageGate.EvaluateAsync— if blocked, the slot-fill loop is skipped entirely for that tick (already-running slots are untouched in either case;RunNow/ContinueTask/interactive/planning/daily-prep all bypass the queue and are unaffected). A blocked↔free transition is logged/broadcast (WorkerLog, Warn on block / Info on resume) exactly once per change, not on every tick; the 30 s backstop timer re-evaluates both the throttle and the gate on its own even with no wake signal, so the queue self-recovers once usage drops back under the threshold. - OverrideSlotService — owns
RunNow/ContinueTask; goes throughTaskStateService.StartRunningAsync(caller-driven, serialized by slot lock). - StaleTaskRecovery — startup-only service; calls
TaskStateService.RecoverStaleRunningAsyncto flip orphanedRunningrows toFailed. - External/* — always-on MCP tools for general Claude sessions, scoped to starting and observing sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional
X-ClaudeDo-Keyheader. Registered explicitly inProgram.cs's external app via.WithTools<T>(). Every optional/filter parameter across these tools must carry a C# default value (e.g.string? status = null) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (ExternalMcpToolSchemaTestsguards this by reflection).ExternalMcpExceptionFilter.Wrapis registered as a call-tool filter soInvalidOperationException/ArgumentExceptionmessages survive asMcpException— otherwise the SDK's own catch-all replaces any non-McpExceptionwith a generic "An error occurred invoking 'X'." No external tool returns bareTaskor a nullable payload directly — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record ({ ok/deleted/removed/reset/started: true, <id>, ... }, e.g.DeleteListResult,RunTaskNowResult,ResetFailedTaskResult,RemoveAttachmentResult;SetListConfigResult/SetTaskConfigResultadditionally echo the resulting config so the caller can see which fields were set vs. cleared to null); read tools that may have nothing to return use an explicitFound/Availableflag alongside the nullable payload (TaskConfigResult, pre-existingBatchGetTaskResult,TaskLogResult) instead of returning null outright. Organized by concern:ExternalMcpService— task CRUD + execution:ListTaskLists,ListTasks,GetTask,AddTask,AddSubtask,UpdateTask,UpdateTaskStatus(Idle/Queued/Cancelled/Done—Cancelledgoes throughTaskStateService.CancelAsync(..., allowFromIdle: true), the only caller that opts into cancelling fromIdle;PlanningChainCoordinatorrelies onIdlestaying a no-op there by default, since a child parked back toIdlemid-chain is a manual opt-out signal;Donegoes throughTaskStateService.ForceSetStatusAsync— same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skipreview_task's merge),GetTaskStatusValues,ReviewTask(approve/reject_rerun/reject_park/cancelfor a WaitingForReview task; approve is review+merge exactly like the hub'sApproveReview— unit merge for parents, worktree merge into optionaltargetBranchfor childless tasks, conflicts reported in theReviewTaskResult),RunTaskNow,ContinueTask,CancelTask,DeleteTask; worktree/git:GetTaskWorktree,GetTaskDiff,MergeTask,ContinueMerge,AbortMerge,PreviewMerge(non-destructivegit merge-tree --write-treemergeability check for one task's worktree branch againsttargetBranch, default the repo's current branch — status/conflictFiles/changedFileCount plusbehind; throws a clear error instead of TaskMergeService.PreviewAsync's silent "unavailable" when the task has no worktree, the worktree isn't Active, or the list's working dir is missing),PreviewMergeSet(same preview for a batch of task ids plus a file→tasks overlap report built from each task's own diff-stat — a same-file-name hint only, blind to cross-file collisions like the CS0103 case that motivated it; a task that fails to preview getserrorset and is excluded from the overlap instead of aborting the batch),RevertMerge(undoes a previously merged task's merge commit ontargetBranchviagit revert -m 1— a new commit, never a reset/rewrite, since the target working directory is shared with other sessions; requires the task to beDonewith aMergedworktree carrying a recordedWorktreeEntity.MergeCommit— a task merged before that field existed has none and is refused rather than guessed viagit log; on success the task returns toWaitingForReviewand the worktree moves toKept, notActive(its directory/branch are typically already gone from the original merge's cleanup) and notMerged/Discarded(WorktreeMaintenanceServicesweeps those); a conflicting revert is aborted immediately, no half-resolved state is ever left in the tree),ListWorktrees,CleanupTaskWorktreeBatchMcpTools— best-effort batch variants that loop theExternalMcpServicesingle-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task):BatchGetTasks,BatchAddTasks,BatchUpdateTaskStatus,BatchCancelTasks,BatchDeleteTasks,BatchSetMyDay,BatchCleanupTaskWorktrees. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.ListMcpTools—CreateList,UpdateList,DeleteListConfigMcpTools—GetListConfig,SetListConfig,GetTaskConfig,SetTaskConfigRunHistoryMcpTools—ListRuns,GetRun,GetTaskLog(latest run's log, tail-capped at 256 KB)AgentMcpTools—ListAgentsLifecycleMcpTools—ResetFailedTaskAppSettingsMcpTools—GetAppSettings(read-only; includesMaxParallelExecutions)TaskWaitMcpTools—WaitForTaskChange(taskIds, timeoutSeconds = 60): blocks until any given task leavesQueued/Running, or times out; returns immediately for a task already outsideQueued/Running(unknown ids reported as status"NotFound", also immediate). Implemented as an async DB poll (short-livedDbContextper check, 500ms delay between checks, no held connection or busy loop) rather than hookingHubBroadcaster— kept deliberately isolated so it can't regress the existing broadcast callers.timeoutSecondsis clamped server-side toTaskWaitMcpTools.MaxTimeoutSeconds(170s), comfortably under the list handler'sMCP_TOOL_TIMEOUT(200s, seeInteractiveLaunchSpecService) so the tool reportstimedOut: trueinstead of racing the client's own abort. Replaces the list handler's old "sleep + poll get_task in a loop" Phase 3 instruction (PromptFiles.MergeHelperDefault).AttachmentMcpTools—AddTaskAttachment(taskId, fileName, textContent?|base64Content?),ListTaskAttachments,RemoveTaskAttachment. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.ExternalMcpServicealso exposes two daily-prep tools:GetDailyPrepCandidates— returns Idle, non-blocked tasks in a git repo NOT excluded byAppSettings.ReportExcludedPathsand not alreadyIsMyDay, plus the current Idle MyDay tasks andmaxTasks(=DailyPrepMaxTasks). Repo-exclusion logic lives in theDailyPrepFilterhelper (same file).SetMyDay— sets a task'sIsMyDay(+ optionalSortOrder); server-side cap-guard rejects turning on MyDay beyondDailyPrepMaxTasksopen (Idle) MyDay tasks.
Daily Prep (Prime Claude)
- PrimeScheduler (hosted
BackgroundService) computes the next due time from theprime_schedulestable and at that time callsIPrimeRunner.FireAsync. A manual run arrives viaWorkerHub.RunDailyPrepNow. ASemaphoreSlimsingle-flight gate inPrimeRunnerprevents overlapping runs (returns "already running"); both scheduled and manual runs go through it. - PrimeRunner builds a fixed prompt via
DailyPrepPrompt.BuildPrompt, parameterized byAppSettings.DailyPrepMaxTasksand today's date, then invokes:It relies on the globally-registeredclaude -p --output-format stream-json --verbose --permission-mode acceptEdits --max-turns 30 --allowedTools mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_dayclaudedoMCP (installer'sRegisterMcpStep) — no separate--mcp-config. This replaced the old warm-up "ping". - Each stdout line is streamed to the UI via
IPrimeBroadcaster.PrepLineAsyncAND written toDailyPrepPrompt.LogPath()=<appdata>/logs/daily-prep.log(truncated at the start of each run → last run only).PrepStarted/PrepFinishedevents bracket the run. - Agentic behaviour: Claude calls
get_daily_prep_candidates, picks an effort-aware subset capped atDailyPrepMaxTasks, and marks them viaset_my_day(which broadcastsTaskUpdatedso the UI updates live).
Usage Monitor & Gate
Source: GET https://api.anthropic.com/api/oauth/usage, an undocumented Anthropic API,
authenticated with the Bearer access token Claude Code itself keeps fresh at
~/.claude/.credentials.json — ClaudeDo reads that token but never refreshes it and never
logs it. UsageMonitorService polls on usage_poll_interval_seconds (default 60s, one poll
at startup too) and broadcasts HubBroadcaster.UsageUpdated after every tick.
The gate (IUsageGate, thresholds usage_gate_five_hour_pct/usage_gate_seven_day_pct)
pauses only the queue's slot-fill loop (new tasks don't start) once five_hour >= usage_gate_five_hour_pct or seven_day >= usage_gate_seven_day_pct; already-running runs,
and RunNow/ContinueTask/interactive ConPTY/planning sessions/daily-prep all bypass the
queue and are unaffected. Fail-open: no snapshot yet, a failed last poll, or an
app-settings read error all resolve to not-blocked, so an outage of the (undocumented, can
change without notice) usage endpoint never blocks work. There's no persistent pause state —
recovery is just the queue's 30s backstop timer re-evaluating the gate on its own once usage
drops back under the threshold. See Usage/ in the folder layout above for the component
breakdown.
Ahead of that hard gate, UsageThrottle steps the queue's effective parallelism down in two
stages (thresholds usage_throttle_soft_pct/usage_throttle_hard_pct, defaults 50/65):
whichever of 5h/7d is more utilized decides the stage — below soft = full configured
max_parallel_executions, at/above soft = capped to 2 slots, at/above hard = capped to 1,
at/above either gate threshold = 0 (the pre-existing hard pause, unchanged). Only new slot
fills are affected; a run already occupying a slot when the stage tightens keeps running to
completion. Same fail-open policy as the gate — no snapshot yet means no throttling, full
configured parallelism. The effective stage (configured vs. effective slots, decisive bucket)
rides along on UsageSnapshotDto/GetUsageSnapshot for UI display (UsagePillViewModel
tooltip, UsageMonitorModalViewModel's throttle band) — it does not change what the gate
itself gates on.
Status Model
TaskEntity carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated.
| Field | Values | Meaning |
|---|---|---|
Status |
Idle, Queued, Running, WaitingForChildren, WaitingForReview, Done, Failed, Cancelled |
Lifecycle only. WaitingForChildren = parent's own work is done, waiting on its children. |
PlanningPhase |
None, Active, Finalized |
Parent-only marker. Active ≈ legacy Planning; Finalized ≈ legacy Planned. |
BlockedByTaskId |
nullable FK | Replaces legacy Waiting. A queued row with BlockedByTaskId != NULL is skipped by the picker. |
IsManual |
bool | Reminder only the user can do. TaskStateService.EnqueueAsync/StartRunningAsync refuse it, the queue picker skips it, and GetDailyPrepCandidates never offers it. An interactive ConPTY session is still allowed. |
ReviewFeedback |
nullable string | Reviewer's rejection comment. Set by RejectToQueueAsync; consumed and cleared by QueueService on the next re-run (resumes the Claude session with it as the next-turn prompt). |
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: RunAsync can fail before StartRunningAsync is called)
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, advanced by the single
TaskStateService.TryAdvanceParentAsync (surfaces any WaitingForChildren parent for
review once all children are terminal; failed/cancelled children are annotated on the
result, not wedged). A planning parent enters WaitingForChildren at
FinalizePlanningAsync (or WaitingForReview directly if it has no children); an
improvement parent enters it from TaskRunner.HandleSuccess when its run spawned
children. Planning/improvement children still go straight to Done (no individual
review) — only the parent is reviewed.
Approve = merge the whole unit. ApproveReview/review_task approve, for a task
that has children, drives PlanningMergeOrchestrator (merges the parent worktree if
Active + each Done child in order, sets the parent Done, and on a mid-merge
conflict pauses for ContinuePlanningMerge/AbortPlanningMerge). Childless tasks use
TaskMergeService.ApproveAndMergeAsync. There is no separate "Merge all" entry —
approve is the single review+merge action.
Post-merge verify gate. A list can set ListConfigEntity.VerifyCommand (List Settings
modal → Verification). Null/blank (the default) = no gate, behavior is bit-identical to
before this existed. When set, TaskMergeService runs it via VerifyCommandRunner
(cmd.exe /c <command>, 10-minute fixed timeout, output tail-captured) in list.WorkingDir
right after a successful MergeNoFfAsync/ContinueMergeAsync and worktree cleanup, but
before the task is allowed to reach Done. Exit 0 → unchanged flow (worktree marked
Merged, task Done if it was WaitingForReview). Non-zero exit or a timeout → the git
merge is deliberately left in place (no auto-revert — that's a separate, unbuilt feature),
the worktree is still marked Merged (it's already gone from disk when removeWorktree
was requested), but the task stays out of Done and MergeResult.Status comes back
TaskMergeService.StatusVerifyFailed ("verify_failed") with an output excerpt in
ErrorMessage — this flows through MergeResultDto (hub) and ReviewTaskResult
(review_task MCP tool) unchanged, since both already treat any non-blocked/conflict
status generically. A process-wide ConcurrentDictionary<string, SemaphoreSlim> keyed by
list.WorkingDir serializes MergeAsync/ContinueMergeAsync (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build. Review transitions live in TaskStateService
(SubmitForReviewAsync, SubmitForChildrenAsync, ApproveReviewAsync,
RejectToQueueAsync, RejectToIdleAsync, ClearReviewFeedbackAsync).
Planning Flow
PlanningSessionManager.FinalizeAsync is the single path:
_state.FinalizePlanningAsync(parent)flips parentPlanningPhasetoFinalizedand setsStatustoWaitingForChildren(orWaitingForReviewif the parent has no children).PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)establishes the blocked-by chain (BlockOns 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), which sets every non-terminal childQueuedand re-applies the chain.- 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 roadblocks) does not advance the parent — the parent stays in WaitingForChildren until every child is terminal. The UI surfaces blocked children on the parent's Session tab (ChildOutcomes + a "children need attention" band) so the roadblock is visible without forcing a transition.
TaskRepository.FinalizePlanningAsync no longer exists. The Mark*Async repository helpers are internal — only TaskStateService calls them.
Task Execution Pipeline
TaskRunner orchestrates:
- Load task + list metadata from DB; resolve config from
list_config+ task-level overrides (model, system_prompt, agent_path) - Create worktree (if
WorkingDirset) or sandbox directory - Mark task "running", broadcast
TaskStarted - Resolve the effective model (task → list →
AppSettings.DefaultModel), then take itsModelPresetsrow viaModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns): the model string is resolved throughModelRegistry.TryNormalizeAliasfirst, so a full CLI model id (e.g.claude-sonnet-4-6, not just the baresonnet/opus/haiku/fablealiases) still hits its alias's preset row; only a truly unrecognized model falls back to a synthesized row usingAppSettings.DefaultMaxTurns(never a hardcoded number, and never throws — an unknown model must not block a run). The preset supplies--effortand the global max-turns default (task/listMaxTurnsoverrides still win). The task/list/global-resolved value is then hard-clamped toAppSettings.MaxTurnsCeiling(default 80) viaTaskRunner.ResolveMaxTurns— a task or list override above the ceiling still starts, just capped, and a Warn is logged with the task id, requested, and effective value. Build CLI args viaClaudeArgsBuilder; inject attachment absolute paths viaTaskPromptComposer.Compose(appends a read-only "## Reference files" section); invokeClaudeProcesswith task prompt - Stream NDJSON output through
StreamAnalyzer; lines forwarded to log file and SignalR (TaskMessage) - On success: auto-commit changes (worktree only), store run record, mark "done"
- On failure: retry once if session ID available (
--resume), then mark "failed"
Key Components
- ClaudeProcess — spawns
claude -p --output-format stream-json --verbose --permission-mode auto(or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree). - ClaudeArgsBuilder — dynamically constructs CLI args; supports
--model,--effort,--max-turns,--append-system-prompt,--agents,--json-schema,--resume - StreamAnalyzer — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
- TaskResetService — discards a failed task's worktree and resets the task row to Idle; preserves run history.
- WorktreeManager — creates worktrees at
claudedo/{taskId[:8]}branches, commits changes with semantic messages, updates DB with head commit and diff stats - CommitMessageBuilder — formats
{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId - AgentFileService — manages
~/.todo-app/agents/*.mdagent definition files; exposes list/refresh via SignalR - LogWriter — async StreamWriter wrapper, auto-creates parent dirs
Execution History
Each CLI invocation is recorded in the task_runs table via TaskRunRepository:
- Fields:
session_id, turn count,resulttext, structured output JSON, and the four raw token fields (tokens_in/tokens_out/cache_read_tokens/cache_write_tokens) — not read from the stream-json "result" event'susage.input_tokens(that's only the uncached remainder of one API call and undercounts the real prompt size by orders of magnitude once caching kicks in). InsteadTaskRunner.ApplyUsageAsyncreadsITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)— the session transcript's cumulative totals across every assistant message — and stores the delta against priortask_runsrows sharing the samesession_id, so a--resume'd run doesn't double-count the turns already billed to an earlier run. A missing/unreadable transcript leaves all four fieldsnull; it never fails the run. - Enables auto-retry on failure (resume last session) and multi-turn follow-up via
ContinueAsync
Multi-Turn / Continue
TaskRunner.ContinueAsync sends a follow-up prompt to an existing Claude session using --resume <session_id> with the stored session ID from the last run.
SignalR Hub
WorkerHub methods, grouped:
- Execution:
Ping,GetActive,RunNow,CancelTask,WakeQueue,ContinueTask,ResetTask,SetTaskStatus,RefineTask - Review/merge:
ApproveReview(taskId, targetBranch) -> MergeResultDto(childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drivesPlanningMergeOrchestratorto merge the whole unit),ContinuePlanningMerge/AbortPlanningMerge(resolve a unit-merge conflict),PreviewMerge(taskId, targetBranch) -> MergePreviewDto(non-destructive mergeability check),RejectReviewToQueue,RejectReviewToIdle,CancelReview,MergeTask,GetMergeTargets - Single-task conflict resolver (Layer C):
StartConflictMerge,GetMergeConflictDocuments(segments),WriteConflictResolution,ContinueConflictMerge,AbortConflictMerge(service-levelTaskMergeService.ContinueMergeAsync/AbortMergeAsynckeep their names) - Planning sessions:
StartPlanningSession,ResumePlanningSession,DiscardPlanningSession,FinalizePlanningSession,QueuePlanningSubtasks,GetPendingDraftCount,GetPlanningAggregate(per-subtask diffs),BuildPlanningIntegrationBranch(combined diff) - Interactive sessions (embedded ConPTY, UI process):
GetInteractiveLaunchSpec,GetAdHocLaunchSpec,GetMergeHelperLaunchSpec,CreateMergeHelperTask(creates the ClaudeDo task that owns a list-handler run —Idle/IsManual=true,HandlerBaseCommitstamped to the list repo's current HEAD viaInteractiveLaunchSpecService.CreateMergeHelperTaskAsync— called by the UI before it opens the task-based ConPTY tile),SubmitTaskForReview(branches on whether the task has aWorktreeEntity: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stampsHandlerHeadCommitto the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). Every ConPTY spec thatInteractiveLaunchSpecServicebuilds passes--effort <level>from the relevant model's preset (task/list model for a task session,PlanningAliasfor planning, list config for the list handler, global default for ad-hoc) — it leads the args except for a fresh task session with a brief, where--add-dir <sessionDir>must come first so--effort(a single-value flag) can sit directly before the positional kickoff (see below).--modelis deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (BuildForMergeHelperAsync) uses--permission-mode autoso it runs unattended; the--allowedToolsallowlist (mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill) remains the security boundary. No ConPTY path ever passes task free-text (title/description/brief) as a CLI argument — every one of them (task session, planning start/resume, list handler) writes it to a file first and hands claude a single-line kickoff pointing at that file, exposed via--add-dir. Reason: the ConPTY host flattensArgsinto one command line to spawn the process, and claude re-splits that line on whitespace, so any token starting with-(e.g.->,--abort) in real task text would be misread as an unknown option — and a raw multi-line positional prompt truncates at its first newline regardless. A fresh task session's brief lives at~/.todo-app/task-sessions/<taskId>/brief.md(InteractiveLaunchSpecService.BuildFreshTaskArgsAsync); a task with neither title nor description skips the file and the positional arg entirely. - Worktrees:
CleanupFinishedWorktrees,ResetAllWorktrees,GetWorktreesOverview,SetWorktreeState,ForceRemoveWorktree - Agents/settings/lists:
GetAgents,RefreshAgents,RestoreDefaultAgents,GetAppSettings,UpdateAppSettings,UpdateList,UpdateListConfig,GetListConfig,UpdateTaskAgentSettings - Reports/notes/prep:
GetWeekReport,GenerateWeekReport,GetDailyNotes,AddDailyNote,UpdateDailyNote,DeleteDailyNote,RunDailyPrepNow,ClearMyDay,GetLastPrepLog,ListPrimeSchedules,UpsertPrimeSchedule,DeletePrimeSchedule - Diagnostics:
GetRecentLogs(last 30 min of buffered log records, all levels, for the Log Visualizer overlay) - Usage:
GetUsageSnapshot() -> UsageSnapshotDto(built byUsageSnapshotBuilderfromUsageState+IUsageGate+AppSettingsgate thresholds; percentages/limits/FetchedAtUtcnull andIsStale=truewhen no snapshot has landed yet;IsStalealso trips on a failed last poll or a snapshot older than 3×usage_poll_interval_seconds),GetModelUsage(from, to) -> IReadOnlyList<ModelUsageRowDto>(thin wrapper overITranscriptUsageReader.ReadAsync),GetTaskUsage(from, to) -> IReadOnlyList<TaskUsageRowDto>(top consumers fromtask_runsjoined to task/list, grouped per task —Runs/summedTokensIn/TokensOut(null token columns count as 0, never dropped),Modelfrom that task's most recent run — sorted by total tokens descending, capped at 100)
HubBroadcaster events: TaskStarted, TaskFinished, TaskMessage, WorktreeUpdated, TaskUpdated, RunCreated, ListUpdated, WorkerLog, PrimeFired, PrepStarted, PrepLine, PrepFinished, PlanningMergeStarted, PlanningSubtaskMerged, PlanningMergeConflict, PlanningMergeAborted, PlanningCompleted, RefineStarted, RefineFinished, UsageUpdated (carries the same UsageSnapshotDto as GetUsageSnapshot; UsageMonitorService fires it after every poll cycle, success or failure, via the shared UsageSnapshotBuilder)
WorkerLog carries two sources: the hand-curated business events (_broadcaster.WorkerLog(...) in TaskRunner/TaskMergeService/TaskResetService) and every Serilog Warn/Error event, 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
Loaded from ~/.todo-app/worker.config.json:
db_path,sandbox_root,log_rootworktree_root_strategy("sibling" | "central"),central_worktree_rootqueue_backstop_interval_ms(default 30000)signalr_port(default 47821)claude_bin(path to claude CLI)online_inbox— Online Inbox config (default:enabled=false, zero network when disabled):enabled(bool, default false) — when false the entireOnline/stack is not registeredapi_base_url(string) — must be HTTPS or loopback; validated at startup when enabledpoll_interval_seconds(int, default 60)zitadel.authority,zitadel.client_id,zitadel.scopes— used byZitadelAuthProvider(OIDC discovery + refresh-token flow)- The refresh token is NOT in this file — stored encrypted via DPAPI at
~/.todo-app/online-inbox.token
usage_poll_interval_seconds(default 60, clamped to a minimum of 15 on load) — poll interval forUsageMonitorService
Per-list config (list_config in DB) provides defaults for model, system_prompt, agent_path; tasks can override each individually. Task-generating MCP tools (AddTask, planning CreateChildTask, SuggestImprovement) accept an optional model (alias-validated via ModelRegistry.NormalizeAlias — haiku/sonnet/opus, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (ModelRegistry.ByCostAscending = the cost order). Planning's CreateChildTask additionally accepts an optional maxTurns (positive int; 0/negative rejected with ArgumentException, null = inherit list/global default) so the planner can raise the turn budget for a subtask it knows will run long; SuggestImprovement/AddTask do not expose it.
Notes
- The worker runs standalone — start it separately from the UI
- Only listens on loopback (127.0.0.1)
- ClaudeProcess uses
--permission-mode autoby default; legacy "bypassPermissions" settings are mapped toautoat dispatch time.acceptEdits,plan, anddefaultpass through unchanged. - Worktree branches follow
claudedo/{id}naming convention