Adds Usage/ with ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at ~/.claude/.credentials.json, calls the Anthropic OAuth usage endpoint, defensively parses buckets/limits), UsageState (threadsafe last-good-snapshot holder that never regresses on failure), and UsageMonitorService (BackgroundService polling on the new usage_poll_interval_seconds config, min 15s, one poll at startup, warns at most once per distinct error).
23 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, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
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); interface in Usage/Interfaces/ (IUsageClient)
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. - 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'." 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,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)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).
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. 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). 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, input/output/cache token counts, turn count,resulttext, structured output JSON - 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 leads with--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).--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. - 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)
HubBroadcaster events: TaskStarted, TaskFinished, TaskMessage, WorktreeUpdated, TaskUpdated, RunCreated, ListUpdated, WorkerLog, PrimeFired, PrepStarted, PrepLine, PrepFinished, PlanningMergeStarted, PlanningSubtaskMerged, PlanningMergeConflict, PlanningMergeAborted, PlanningCompleted, RefineStarted, RefineFinished
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).
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