32 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, ConnectedClientTracker (connected-UI-client count)
Logging/ — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal
Online/ — optional Online Inbox sync (off by default; zero network when disabled)
Tickets/ — Bandel ticket-system integration (per-list import + status write-back),
inactive without ticket_api_base_url: TicketSystemConfig/TicketClientFactory,
BandelTicketClient, TicketDtos, TicketStatusMap, TicketStatusSync,
TicketImportService
Usage/ — OAuth usage monitor, gate, throttle, per-session token reader;
TokenTracker/ = the external analytics backend (cost + per-model/per-task breakdown)
Interfaces (IQueueWaker, IPrimeClock, ITaskStateService, …) live in an Interfaces/
subfolder within their area; the namespace stays the area namespace.
Architecture
- Program.cs — loads config, inits schema, registers DI, configures SignalR on
/hub, binds to127.0.0.1:47821 - TaskStateService — the only component that writes
Status,PlanningPhase,BlockedByTaskId, and thetask_dependenciesedges. All transitions return aTransitionResult(no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions.SubmitForChildrenAsync(Running → WaitingForChildren) also chains and immediately queues a normal parent's own Idle/Queued children viaPlanningChainCoordinator.SetupChainAsync(enqueue: true)— unlike a planning parent (PlanningPhase != None), whose children stay untouched here and wait for the user's explicit "Queue plan" (QueuePlanAsync); a child alreadyRunningmakesSetupChainAsyncthrow, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a privateNotifyAsync(taskId)— never call_broadcaster.TaskUpdateddirectly from this class,NotifyAsyncalso drivesTicketStatusSync.SyncAsync(see Tickets below) and a direct call skips that transition's ticket write-back.SetDependsOnAsyncreplaces the task's whole dependency set (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the wholetask_dependenciesedge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten. - IQueueWaker / IQueuePicker / QueueService — waker is a singleton
SemaphoreSlim; picker performs the atomicQueued → Runningclaim filtered byBlockedByTaskId IS NULL,is_manual = 0, schedule, and "notask_dependenciesedge whose predecessor isn't satisfied" — satisfied =status = 'done'or a landed merge (worktrees.state = 'merged'), so a predecessor merged withskipVerifyand parked inWaitingForReviewuntil the batchverify_mergesno longer stalls its successors (doubly negated on purpose: zero edges stays eligible, all edges must be satisfied, and an edge pointing at a vanished task keeps blocking); QueueService is a thinBackgroundServicelooping on the waker, dispatching viaTaskRunner. Per tick it also applies the usage throttle and gate → usage-monitoring. - RunCancellationRegistry — taskId → running-run CTS. Lets
TaskStateService.CancelAsynckill a cancelled task's process without a DI cycle. - OverrideSlotService — owns
RunNow/ContinueTask; goes throughTaskStateService.StartRunningAsync(caller-driven, serialized by slot lock). - StaleTaskRecovery — startup-only; calls
TaskStateService.RecoverStaleRunningAsyncto flip orphanedRunningrows toFailed. - External/* — always-on MCP tools for general Claude sessions, scoped to starting and observing sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional
X-ClaudeDo-Key. Task-ID resolution: every tool parameter that accepts a task id (taskId,parentId,taskIds, etc.) is wired throughTaskIdResolver, which accepts both#123(short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Task worktree folders are named<nummer>-<slug>, branches<typ>/<nummer>-<slug>(stored in theworktreesrow, never re-derived; rules under WorktreeManager below) — the taskNumberis otherwise a display alias, never identity. Every task-returning tool stamps the task'sNumberin its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to refer to tasks as#<number>when reporting to the user (otherwise the number sits unused in the payload). Hard convention (test-enforced): every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists;ExternalMcpToolSchemaTestsguards this. Registration trap: the external server is a secondWebApplicationwith its own DI container — a new tool class needsexternalBuilder.Services.AddScoped<T>(), aShare<Dep>()for every main-container singleton it injects, and.WithTools<T>(); miss one and the tool fails only when called (no test builds that container). Other conventions (not test-enforced): no tool returns bareTask/a nullable payload directly (write tools return a confirmation record likeRunTaskNowResult; read tools use an explicitFound/Availableflag); tool descriptions follow rules documented inExternal/McpToolDocs.cs. Full tool inventory + per-tool behaviour → external-mcp.
Status Model
TaskEntity carries orthogonal fields — lifecycle, planning hierarchy, and chain blocking are
not conflated.
| Field | Values | Meaning |
|---|---|---|
Status |
Idle, Queued, Running, WaitingForChildren, WaitingForReview, Done, Failed, Cancelled |
Lifecycle only. WaitingForChildren = parent's own work done, waiting on children. |
PlanningPhase |
None, Active, Finalized |
Parent-only marker. Active ≈ legacy Planning; Finalized ≈ legacy Planned. |
BlockedByTaskId |
nullable FK | Replaces legacy Waiting. A queued row with a non-null value is skipped by the picker. Internal to PlanningChainCoordinator — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, wait_for_task_change deliberately keeps waiting through it instead of reporting "Blocked". |
Dependencies |
0..n rows in task_dependencies |
User/MCP-declared predecessors (add_task/update_task), separate from BlockedByTaskId because the semantics differ: the picker skips a queued row until every dependency's merge has landed — Done, or worktrees.state = 'merged' while the batch verify_merges is still pending. A Failed/Cancelled dependency does not cascade or auto-resolve, the dependent just stays blocked; revert_merge/abort_merge put the worktree back to Active and re-block the successors; a failing verify_merges does not (the code is on the target branch either way). See QueuePicker, TaskStateService.SetDependsOnAsync. get_task/list_tasks/batch_get_tasks surface this as dependsOnTaskIds + blocked/blockedReason (the reason names all open predecessors, via External/TaskBlocking); wait_for_task_change reports "Blocked" instead of silently running out its timeout. |
IsManual |
bool | Reminder only the user can do. EnqueueAsync/StartRunningAsync refuse it, the picker skips it, GetDailyPrepCandidates never offers it. An interactive ConPTY session is still allowed. |
ReviewFeedback |
nullable string | Reviewer's rejection comment; consumed and cleared by QueueService on the next re-run. |
Allowed transitions (enforced by TaskStateService):
Idle → Queued | Running (RunNow) | Cancelled (external update_task_status only, allowFromIdle: true)
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap)
Running → WaitingForReview (standalone success, no children)
| WaitingForChildren (parent with pending children)
| Done (planning/improvement child success) | Failed | Cancelled
WaitingForChildren → WaitingForReview (all children terminal) | Cancelled
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
Done → Idle (re-run)
Failed → Idle | Queued
Cancelled → Idle | Queued
any except Running → Done (manual done toggle, SetTaskDone)
The manual done toggle is the one cross-cutting transition — it ticks a task off from any
status except Running, skipping the merge on purpose (a worktree-less list-handler task has
nothing to merge; a worktree task's branch just stays Active for the worktrees overview).
Unified parent model. Every parent — planning or improvement — flows
… → WaitingForChildren → WaitingForReview → Done via the single TryAdvanceParentAsync.
Planning/improvement children go straight to Done; only the parent is reviewed.
Unit branch. The parent's own worktree is the unit branch (improvement parents have it from
their run, planning parents get it in QueuePlanAsync via UnitBranchService.EnsureUnitWorktreeAsync).
Children fork from its current head and UnitBranchService.AbsorbChildAsync fast-forwards it to
the child (merge --ff-only in the parent worktree) — from TaskRunner.HandleSuccess right before
CompleteAsync, and from TaskMergeService.ApproveAndMergeAsync for an interactive child in
review. The child row goes Merged with MergeCommit null (so revert_merge only works on the
whole unit), its worktree and branch are removed, the parent row's HeadCommit/DiffStat move
along. A child that can't fast-forward (e.g. started in parallel via Run now) is Failed — no
merge fallback; in the approve path it stays WaitingForReview with a blocked result.
A planning parent is only approvable once every child is Done — ApproveAndMergeAsync
blocks with subtask X is not Done (status …), the orchestrator's rule, so a failed/cancelled child
never lands half a plan on the target (improvement parents have no such gate, as before).
UnitBranchService never writes task status (it would close a DI cycle through
PlanningChainCoordinator). ⚠️ QueuePicker counts worktrees.state='merged' as a satisfied
dependency, so an absorbed child satisfies a dependsOn edge although nothing reached main yet.
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:
_state.FinalizePlanningAsync(parent)flipsPlanningPhasetoFinalizedandStatustoWaitingForChildren(orWaitingForReviewif childless).PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)establishes the blocked-by chain (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).- Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via
OnChildFinishedAsync.
QueuePlanAsync first gives the parent its worktree (the unit branch, see Unified parent model);
the integration branch in PlanningAggregator (planning/{slug}-integration, checkout -b in the
main checkout) is only built for legacy units — a parent that owns the unit returns
unitBase..unitBranch as its combined diff.
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 --versionruns; resolves the binary viaExecutableResolverfirst and returns a clear "not found on PATH" result instead of a rawProcess.Startexception when it doesn't. (Not yet onmain— 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.ClaudeBinviaExecutableResolver(ClaudeDo.Data, shared withClaudeCliPreflightand the Installer's checks) before spawning — a.cmd/.batshim (e.g. an npm-installedclaude) is launched throughcmd.exe /csinceUseShellExecute = falsecan't exec a shim directly; a resolved.exestarts exactly as before. Throws if nothing resolves. (Not yet onmain— seeEnvironment ChecksinClaudeDo.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
<typ>/<nummer>-<slug>branches, same leaf as the folder (NameFor, slug capped at 40 chars, umlauts transliterated). Typ fromBranchTypeFor(feat→feature,fix/refactor/docs/test/choreas-is, anything elsechore). Nummer = ticket id (ownTicketRefor the parent's), else the task number — in a company repo (CompanyRepo, origin matchescompany_remote_pattern) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base =rev-parse <unit branch>for a child whose parent has an Active worktree, elselist_config.base_branch, else the checked-out HEAD. Commits with semantic messages, updates DB with head commit + diff stats - CommitMessageBuilder —
{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId;BuildMergeis the merge-commit variant ({commitType}(slug): merge title+ trailer). Every merge caller passes a blank commit message on purpose —TaskMergeServicefills inBuildMergefrom the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal. - TaskResetService — discards a failed task's worktree, resets the row to Idle, preserves run history
- AgentFileService — manages
~/.claudeDo/agents/*.md; list/refresh via SignalR - LogWriter — async StreamWriter wrapper, auto-creates parent dirs
Each CLI invocation is recorded in task_runs via TaskRunRepository. ⚠️ Token fields come from
the session transcript, not the stream-json result event, as a per-run delta →
usage-monitoring. TaskRunner.ContinueAsync sends
a follow-up prompt to an existing session via --resume <session_id>.
Prime Claude
Every schedule carries a PrimeActionKind (Ping — the default for new schedules — FillMyDay,
ImportTickets, or Custom). PrimeRunner.FireAsync holds one SemaphoreSlim gate for all kinds, then
branches:
| Kind | Prompt | Args | Log + Prep* events |
|---|---|---|---|
Ping |
PrimePrompts.PingPrompt, fixed |
--max-turns 1 --strict-mcp-config (no MCP server loads at all) |
none |
FillMyDay |
DailyPrepPrompt.BuildPrompt (+ the schedule's prompt as an addition) |
the two MyDay MCP tools | daily-prep.log, truncated per run, + PrepStarted/PrepLine/PrepFinished |
ImportTickets |
TicketImportPrompt.BuildPrompt, then the FillMyDay prompt (override appended to both) |
step 1: get_ticket_import_candidates + import_ticket, max-turns 60; step 2: as FillMyDay |
one daily-prep.log for both steps, one PrepStarted/PrepFinished |
Custom |
the schedule's prompt verbatim | --allowedTools mcp__claudedo — no Read/Write/Edit/Bash |
none |
Ping and Custom deliberately write no log and raise no Prep* events: the prep log
belongs to the MyDay selection and would otherwise be overwritten with ping noise. All four
kinds still broadcast PrimeFired and update last_run_at.
WorkerHub.RunDailyPrepNow (the "Plan day" button) pins its synthetic schedule to FillMyDay —
the button means "fill my day" no matter how the schedules are configured.
WorkerHub.RunTicketImportNow (the "Tickets + plan day" button) does the same for ImportTickets.
A failed import step never blocks the FillMyDay step; without a configured ticket system the kind
runs plain FillMyDay.
WorkerHub.UpsertPrimeSchedule runs PrimeScheduleValidation.Validate first, so a Custom
schedule can never be saved without a prompt.
PrimeScheduler (BackgroundService) computes the next due time from prime_schedules and
calls IPrimeRunner.FireAsync; manual runs arrive via WorkerHub.RunDailyPrepNow. The
SemaphoreSlim single-flight gate lives in PrimeRunner, not the scheduler, so both paths go
through it (returns "already running").
PrimeRunner builds a fixed prompt via DailyPrepPrompt.BuildPrompt (parameterized by
AppSettings.DailyPrepMaxTasks + today's date), then runs claude -p with
--permission-mode acceptEdits --max-turns 30 and --allowedTools mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day. It relies on the
globally-registered claudedo MCP (installer's RegisterMcpStep) — no separate --mcp-config.
Claude then calls get_daily_prep_candidates, picks an effort-aware subset capped at
DailyPrepMaxTasks, and marks them via set_my_day. Each stdout line goes to the UI via
IPrimeBroadcaster.PrepLineAsync and to DailyPrepPrompt.LogPath() =
<appdata>/logs/daily-prep.log, which is truncated at the start of each run (last run only).
PrepStarted/PrepFinished bracket the run.
SignalR Hub
WorkerHub is the canonical method list — grep it rather than trusting a doc inventory. Its wire
DTOs live in ClaudeDo.Data/Wire.cs (shared with IWorkerClient via a <Using> item), not in
WorkerHub.cs — don't re-declare one locally, that's how MergePreviewDto lost its verify fields
on the client side.
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):
TaskStartedTaskFinishedTaskMessageWorktreeUpdatedTaskUpdatedListUpdatedWorkerLogPrimeFiredPrepStartedPrepLinePrepFinishedOperationProgress(generic(opKey, phase, current, total)channel; merge phases are its first producer)PlanningMergeStartedPlanningSubtaskMergedPlanningMergeConflictPlanningMergeAborted(planningTaskId, reason— every call site passes a human-readable reason now, from a real merge failure'sErrorMessageto a plain "Merge aborted."; the UI flashes it viaFlashFooterError)PlanningCompletedRefineStartedRefineFinishedUsageUpdated
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_findingtool registered on both MCP surfaces:External/FindingsMcpTools(needs alistargument; refuses rather than guessing when several lists qualify) andRunner/TaskRunFindingsMcpTools(resolves the list fromTaskRunMcpContextAccessor). Same slug overwrites. Both go throughFindingsStoreLocator, which always resolves to the list'sWorkingDir— the main checkout, never a worktree copy, or parallel runs would collide onINDEX.md. - Read with plain
Read; there is deliberately no read tool.TaskRunner.BuildFindingsPointeradds one system-prompt layer naming the index, and only when the index file exists. ListEntity.FindingsTrackeddecides whether the folder is committed. When false,FindingsStorewrites/.claudedo/to.git/info/excludeviaGitExcludeWriter— per-clone, no tracked file touched.INDEX.mdis read by every run, so it must stay short:FindingsStore.WarnThreshold(80) flipsnearCapacityin the tool result as a prune signal.
Config
~/.claudeDo/worker.config.json:
db_path,sandbox_root,log_rootworktree_root_strategy(sibling|central),central_worktree_rootqueue_backstop_interval_ms(30000) — also the gate/throttle recovery timercompany_remote_pattern(/_git/) — Teilstring der origin-URL, an demCompanyRepoein Firmen-Repo erkennt; nur per Hand editierbar.signalr_port(47821),claude_bin,ticket_api_base_url— the two UI-writable fields (claude_bin: Settings → Dateien → CLAUDE CLI, hubGetClaudeBin/SetClaudeBin;ticket_api_base_url: Settings → Ticketsystem, hubGetTicketSettings/SetTicketApiBaseUrl). Both edit the DI-singletonWorkerConfigin place and persist via their ownSaveKeycall (SaveClaudeBin/SaveTicketApiBaseUrl), so each applies without a restart. Every other field here is hand-edit only.usage_poll_interval_active_seconds/usage_poll_interval_idle_seconds(120 each, clamped to min 90 on load) — measured 2026-09-16: the endpoint allows exactly 5 requests per 5-minute window, then 429s withRetry-After: 300; 120s leaves margin for manual refreshes and other consumers of the same OAuth token, 60s eats the whole quota (see usage-monitoring).online_inbox—enabled(false by default; when false the entireOnline/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~/.claudeDo/online-inbox.token.
Per-list config (list_config in DB) provides defaults for model, system_prompt,
agent_path, max_turns, session_skills, permission_mode; tasks override each individually
(resolution lives in EffectiveRunConfigResolver, so TaskRunner and
get_effective_run_config can't drift). verify_command is list-only — no task-level override —
and is written via set_list_config's verifyCommand parameter (ConfigMcpTools) and the UI's
list config editor; permission_mode, ticket_project_id, base_branch and power_use_allowed
are UI-only (set_list_config preserves but does not expose them; get_list_config returns
baseBranch). base_branch is the base of new worktrees and the default merge target —
TaskMergeService.ResolveTargetAsync resolves an empty target to it (else the checked-out branch),
centrally inside MergeAsync too.
Tickets (Bandel ticket system)
Optional, off by default. TicketSystemConfig.IsConfigured is the single gate (worker.config.json's
ticket_api_base_url set and a PAT present) — false costs nothing: no DB query, no network. The
PAT is DPAPI-encrypted at ~/.claudeDo/ticket.pat via DpapiTokenStore (same class the Online Inbox
refresh token uses, one instance per file, DI markers OnlineRefreshTokenStore/TicketPatStore
distinguish the two singletons). The token never touches worker.config.json or the DB.
- Import (
TicketImportService, hubImportTickets) — per-list, manual only (no polling): pulls the linked project's board (ListConfigEntity.TicketProjectId), keeps tickets whoseStatusIdis1(Offen) or2(InBearbeitung) and that are assigned to the PAT owner, skips anyTicketRefalready present on the list, and creates oneIdletask per remainder viaTaskRepository.AddAsync(so it gets aNumberlike every other task). Already-imported tickets are never updated — a re-import would overwrite task notes. ⚠️ The assignee lives inticketAssignees[], never in the flatassigneeName— that field is null on every ticket the live API returns (checked across all 63 in Abteilung Entwicklung, 2026-09-01), so the original filter imported nothing, ever.personInChargeNameis the wrong source too: it holds the login on some rows and the display name ("Mika Kuns") on others, whileticketAssignees[].assigneeNamematches/pat/me'suserNameformat exactly. - Cross-project import (Prime
ImportTickets, MCPget_ticket_import_candidates/import_ticket) —TicketImportService.GetCandidatesAsync/ImportItemAsyncsweep sidebar → every project board. Tickets match onticketAssignees, tasks (tasks[]) onticketTaskAssignees; board tasks withhasChildrenare skipped. Dedupe onTicketRefis across all lists (also in the per-listImportAsync).import_ticketwithoutlistusesAppSettings.TicketFallbackListId(written only viaSetTicketFallbackListIdAsync/ hubSetTicketFallbackList, never byUpdateAsync); with no fallback set the item is skipped. - Write-back (
TicketStatusSync) hangs offTaskStateService.NotifyAsync— the one place every status transition passes through. Mapping:Running/WaitingForReview→ ticket status2 InBearbeitung,Done→3 Fertig, everything else writes nothing (1 Offenis never written back — it's the entry state). A task without aTicketRef, or aTicketRefnot shapedbandel:<id>, is a no-op before any DB read. Per-process de-dupe (taskId → last-written status) skips the redundantRunning → WaitingForReviewPATCH. Never throws — a ticket-system failure logs a warning and does not block the status transition or the queue. BandelTicketClientwraps the five REST calls (GET /api/ticketsystem/pat/me,GET /api/Navigation/sidebar,GET /api/Board/project/{id},GET /api/ticket/{id}— works for tasks too, live-verified 2026-10-01 —PATCH /api/Ticket/{id}/status); every response is unwrapped fromBandelEnvelope<T>and a non-2xx/success:falsethrowsTicketApiExceptionwith a readable message. The first four are live-verified (2026-09-01) againsthttp://192.168.45.161. ⚠️PATCH /api/Ticket/{id}/statusneeds bothstatusIdandstatusName— omitting the name is a 400 ("The StatusName field is required.") and no status is written. The name is not validated against the id (statusIdwins, any non-empty string passes), but it must be there;TicketStatusMap.NameOfsupplies the canonical name. ⚠️ The base URL must be the IP —bandelapis.fb-tuning.localstill refuses the connection.
Notes
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
--permission-mode autoby default; legacybypassPermissionssettings map toautoat dispatch time.acceptEdits,plan,defaultpass through unchanged.- Task worktree branches follow
<typ>/<nummer>-<slug>(see WorktreeManager); planning worktrees stayclaudedo/planning/{guid}(their path is re-derived from the id on resume).UntrackedWorktreeRecoverytherefore detects task worktrees by path (.claudedo-worktreesor the central root, minusplanning/), not by branch prefix. - Both MCP servers run
Stateless = true(Program.cs). NoMcp-Session-Id, so a worker restart can't 404 a session that outlives it, and a 2026-07-28 client isn't refused back to the initialize handshake. The trade: no sampling, elicitation, resource subscriptions, unsolicited notifications, or legacy SSE endpoint — don't reach for those. In-tool progress notifications ride the POST's own response stream and work in both modes. All three consumers (TaskRunner,PlanningSessionManager, the installer'sRegisterMcpStep) already usetype: "http". - An MCP tool that can run longer than ~5s reports progress. Staying silent lets the MCP client abort after 300s idle while the worker keeps working — the caller sees an abort even though the operation is still running.
Lifecycle/ProgressReporteris the one implementation (elapsed-time reporting viaRunAsync, per-itemi/nviaReportItem) — thread anIProgress<ProgressNotificationValue>? progress = nullparameter through instead of writing another polling loop.