Files
ClaudeDo/src/ClaudeDo.Worker/CLAUDE.md
T

36 KiB
Raw Blame History

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 0100 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 to 127.0.0.1:47821
  • TaskStateService — only component that writes Status, PlanningPhase, BlockedByTaskId. 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.
  • IQueueWaker / IQueuePicker / QueueService — waker is a singleton SemaphoreSlim; picker performs the atomic Queued → Running claim filtered by BlockedByTaskId IS NULL, is_manual = 0 and schedule; QueueService is a thin BackgroundService that loops on the waker and dispatches via TaskRunner. On each loop tick, GetEffectiveMaxParallelAsync reads AppSettings.MaxParallelExecutions and steps it down via UsageThrottle.EffectiveSlots against the current UsageState snapshot (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 asks IUsageGate.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 through TaskStateService.StartRunningAsync (caller-driven, serialized by slot lock).
  • StaleTaskRecovery — startup-only service; 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 worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional X-ClaudeDo-Key header. Registered explicitly in Program.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 (ExternalMcpToolSchemaTests guards this by reflection). ExternalMcpExceptionFilter.Wrap is registered as a call-tool filter so InvalidOperationException/ArgumentException messages survive as McpException — otherwise the SDK's own catch-all replaces any non-McpException with a generic "An error occurred invoking 'X'." No external tool returns bare Task or 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/SetTaskConfigResult additionally 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 explicit Found/Available flag alongside the nullable payload (TaskConfigResult, pre-existing BatchGetTaskResult, TaskLogResult) instead of returning null outright. Organized by concern:
    • ExternalMcpService — task CRUD + execution: ListTaskLists, ListTasks, GetTask, AddTask, AddSubtask, UpdateTask, UpdateTaskStatus (Idle / Queued / Cancelled / DoneCancelled goes through TaskStateService.CancelAsync(..., allowFromIdle: true), the only caller that opts into cancelling from Idle; PlanningChainCoordinator relies on Idle staying a no-op there by default, since a child parked back to Idle mid-chain is a manual opt-out signal; Done goes through TaskStateService.ForceSetStatusAsync — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip review_task's merge), GetTaskStatusValues, ReviewTask (approve / reject_rerun / reject_park / cancel for a WaitingForReview task; approve is review+merge exactly like the hub's ApproveReview — unit merge for parents, worktree merge into optional targetBranch for childless tasks, conflicts reported in the ReviewTaskResult), RunTaskNow, ContinueTask, CancelTask, DeleteTask; worktree/git: GetTaskWorktree, GetTaskDiff, MergeTask, ContinueMerge, AbortMerge, PreviewMerge (non-destructive git merge-tree --write-tree mergeability check for one task's worktree branch against targetBranch, default the repo's current branch — status/conflictFiles/changedFileCount plus behind; 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 gets error set and is excluded from the overlap instead of aborting the batch), RevertMerge (undoes a previously merged task's merge commit on targetBranch via git revert -m 1 — a new commit, never a reset/rewrite, since the target working directory is shared with other sessions; requires the task to be Done with a Merged worktree carrying a recorded WorktreeEntity.MergeCommit — a task merged before that field existed has none and is refused rather than guessed via git log; on success the task returns to WaitingForReview and the worktree moves to Kept, not Active (its directory/branch are typically already gone from the original merge's cleanup) and not Merged/Discarded (WorktreeMaintenanceService sweeps those); a conflicting revert is aborted immediately, no half-resolved state is ever left in the tree), ListWorktrees, CleanupTaskWorktree
    • BatchMcpTools — best-effort batch variants that loop the ExternalMcpService single-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.
    • ListMcpToolsCreateList, UpdateList, DeleteList
    • ConfigMcpToolsGetListConfig, SetListConfig, GetTaskConfig, SetTaskConfig
    • RunHistoryMcpToolsListRuns, GetRun, GetTaskLog (latest run's log, tail-capped at 256 KB)
    • AgentMcpToolsListAgents
    • LifecycleMcpToolsResetFailedTask
    • AppSettingsMcpToolsGetAppSettings (read-only; includes MaxParallelExecutions)
    • TaskWaitMcpToolsWaitForTaskChange(taskIds, timeoutSeconds = 60): blocks until any given task leaves Queued/Running, or times out; returns immediately for a task already outside Queued/Running (unknown ids reported as status "NotFound", also immediate). Implemented as an async DB poll (short-lived DbContext per check, 500ms delay between checks, no held connection or busy loop) rather than hooking HubBroadcaster — kept deliberately isolated so it can't regress the existing broadcast callers. timeoutSeconds is clamped server-side to TaskWaitMcpTools.MaxTimeoutSeconds (170s), comfortably under the list handler's MCP_TOOL_TIMEOUT (200s, see InteractiveLaunchSpecService) so the tool reports timedOut: true instead 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).
    • AttachmentMcpToolsAddTaskAttachment(taskId, fileName, textContent?|base64Content?), ListTaskAttachments, RemoveTaskAttachment. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
    • ExternalMcpService also exposes two daily-prep tools:
      • GetDailyPrepCandidates — returns Idle, non-blocked tasks in a git repo NOT excluded by AppSettings.ReportExcludedPaths and not already IsMyDay, plus the current Idle MyDay tasks and maxTasks (= DailyPrepMaxTasks). Repo-exclusion logic lives in the DailyPrepFilter helper (same file).
      • SetMyDay — sets a task's IsMyDay (+ optional SortOrder); server-side cap-guard rejects turning on MyDay beyond DailyPrepMaxTasks open (Idle) MyDay tasks.

Daily Prep (Prime Claude)

  • PrimeScheduler (hosted BackgroundService) computes the next due time from the prime_schedules table and at that time calls IPrimeRunner.FireAsync. A manual run arrives via WorkerHub.RunDailyPrepNow. A SemaphoreSlim single-flight gate in PrimeRunner prevents overlapping runs (returns "already running"); both scheduled and manual runs go through it.
  • PrimeRunner builds a fixed prompt via DailyPrepPrompt.BuildPrompt, parameterized by AppSettings.DailyPrepMaxTasks and today's date, then invokes:
    claude -p --output-format stream-json --verbose --permission-mode acceptEdits --max-turns 30
           --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. This replaced the old warm-up "ping".
  • Each stdout line is streamed to the UI via IPrimeBroadcaster.PrepLineAsync AND written to DailyPrepPrompt.LogPath() = <appdata>/logs/daily-prep.log (truncated at the start of each run → last run only). PrepStarted/PrepFinished events bracket the run.
  • Agentic behaviour: Claude calls get_daily_prep_candidates, picks an effort-aware subset capped at DailyPrepMaxTasks, and marks them via set_my_day (which broadcasts TaskUpdated so 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:

  1. _state.FinalizePlanningAsync(parent) flips parent PlanningPhase to Finalized and sets Status to WaitingForChildren (or WaitingForReview if the parent has no children).
  2. PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false) establishes the blocked-by chain (BlockOns 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), which sets every non-terminal child Queued and re-applies the chain.
  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 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:

  1. Load task + list metadata from DB; resolve config from list_config + task-level overrides (model, system_prompt, agent_path)
  2. Create worktree (if WorkingDir set) or sandbox directory
  3. Mark task "running", broadcast TaskStarted
  4. Resolve the effective model (task → list → AppSettings.DefaultModel), then take its ModelPresets row via ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns): the model string is resolved through ModelRegistry.TryNormalizeAlias first, so a full CLI model id (e.g. claude-sonnet-4-6, not just the bare sonnet/opus/haiku/fable aliases) still hits its alias's preset row; only a truly unrecognized model falls back to a synthesized row using AppSettings.DefaultMaxTurns (never a hardcoded number, and never throws — an unknown model must not block a run). The preset supplies --effort and the global max-turns default (task/list MaxTurns overrides still win). The task/list/global-resolved value is then hard-clamped to AppSettings.MaxTurnsCeiling (default 80) via TaskRunner.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 via ClaudeArgsBuilder; inject attachment absolute paths via TaskPromptComposer.Compose (appends a read-only "## Reference files" section); invoke ClaudeProcess with task prompt
  5. Stream NDJSON output through StreamAnalyzer; lines forwarded to log file and SignalR (TaskMessage)
  6. On success: auto-commit changes (worktree only), store run record, mark "done"
  7. 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/*.md agent 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, result text, 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's usage.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). Instead TaskRunner.ApplyUsageAsync reads ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId) — the session transcript's cumulative totals across every assistant message — and stores the delta against prior task_runs rows sharing the same session_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 fields null; 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: drives PlanningMergeOrchestrator to 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-level TaskMergeService.ContinueMergeAsync/AbortMergeAsync keep 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, HandlerBaseCommit stamped to the list repo's current HEAD via InteractiveLaunchSpecService.CreateMergeHelperTaskAsync — called by the UI before it opens the task-based ConPTY tile), SubmitTaskForReview (branches on whether the task has a WorktreeEntity: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stamps HandlerHeadCommit to the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). Every ConPTY spec that InteractiveLaunchSpecService builds passes --effort <level> from the relevant model's preset (task/list model for a task session, PlanningAlias for 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). --model is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (BuildForMergeHelperAsync) uses --permission-mode auto so it runs unattended; the --allowedTools allowlist (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 flattens Args into 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 by UsageSnapshotBuilder from UsageState + IUsageGate + AppSettings gate thresholds; percentages/limits/FetchedAtUtc null and IsStale=true when no snapshot has landed yet; IsStale also trips on a failed last poll or a snapshot older than 3× usage_poll_interval_seconds), GetModelUsage(from, to) -> IReadOnlyList<ModelUsageRowDto> (thin wrapper over ITranscriptUsageReader.ReadAsync), GetTaskUsage(from, to) -> IReadOnlyList<TaskUsageRowDto> (top consumers from task_runs joined to task/list, grouped per task — Runs/summed TokensIn/TokensOut (null token columns count as 0, never dropped), Model from 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_root
  • worktree_root_strategy ("sibling" | "central"), central_worktree_root
  • queue_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 entire Online/ stack is not registered
    • api_base_url (string) — must be HTTPS or loopback; validated at startup when enabled
    • poll_interval_seconds (int, default 60)
    • zitadel.authority, zitadel.client_id, zitadel.scopes — used by ZitadelAuthProvider (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 for UsageMonitorService

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.NormalizeAliashaiku/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 auto by default; legacy "bypassPermissions" settings are mapped to auto at dispatch time. acceptEdits, plan, and default pass through unchanged.
  • Worktree branches follow claudedo/{id} naming convention