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

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 to 127.0.0.1:47821
  • TaskStateService — the only component that writes Status, PlanningPhase, BlockedByTaskId, and the task_dependencies edges. All transitions return a TransitionResult (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 via PlanningChainCoordinator.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 already Running makes SetupChainAsync throw, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a private NotifyAsync(taskId) — never call _broadcaster.TaskUpdated directly from this class, NotifyAsync also drives TicketStatusSync.SyncAsync (see Tickets below) and a direct call skips that transition's ticket write-back. SetDependsOnAsync replaces 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 whole task_dependencies edge 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 atomic Queued → Running claim filtered by BlockedByTaskId IS NULL, is_manual = 0, schedule, and "no task_dependencies edge whose predecessor isn't satisfied" — satisfied = status = 'done' or a landed merge (worktrees.state = 'merged'), so a predecessor merged with skipVerify and parked in WaitingForReview until the batch verify_merges no 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 thin BackgroundService looping on the waker, dispatching via TaskRunner. Per tick it also applies the usage throttle and gate → usage-monitoring.
  • RunCancellationRegistry — taskId → running-run CTS. Lets TaskStateService.CancelAsync kill a cancelled task's process without a DI cycle.
  • OverrideSlotService — owns RunNow / ContinueTask; goes through TaskStateService.StartRunningAsync (caller-driven, serialized by slot lock).
  • StaleTaskRecovery — startup-only; calls TaskStateService.RecoverStaleRunningAsync to flip orphaned Running rows to Failed.
  • External/* — always-on MCP tools for general Claude sessions, scoped to starting and observing sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional X-ClaudeDo-Key. Task-ID resolution: every tool parameter that accepts a task id (taskId, parentId, taskIds, etc.) is wired through TaskIdResolver, 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 the worktrees row, never re-derived; rules under WorktreeManager below) — the task Number is otherwise a display alias, never identity. Every task-returning tool stamps the task's Number in 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; ExternalMcpToolSchemaTests guards this. Registration trap: the external server is a second WebApplication with its own DI container — a new tool class needs externalBuilder.Services.AddScoped<T>(), a Share<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 bare Task/a nullable payload directly (write tools return a confirmation record like RunTaskNowResult; read tools use an explicit Found/Available flag); tool descriptions follow rules documented in External/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:

  1. _state.FinalizePlanningAsync(parent) flips PlanningPhase to Finalized and Status to WaitingForChildren (or WaitingForReview if childless).
  2. PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false) establishes the blocked-by chain (child[i] → child[i-1]) but leaves children Idle — finalize never auto-queues. Queueing is a deliberate user action: QueuePlanAsync (hub QueuePlanningSubtasksAsync, the "Queue plan" button) calls SetupChainAsync(parent, enqueue: true).
  3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via OnChildFinishedAsync.

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 --version runs; resolves the binary via ExecutableResolver first and returns a clear "not found on PATH" result instead of a raw Process.Start exception when it doesn't. (Not yet on main — see below.)
  • ClaudeProcess — spawns claude -p --output-format stream-json --verbose --permission-mode auto (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves _cfg.ClaudeBin via ExecutableResolver (ClaudeDo.Data, shared with ClaudeCliPreflight and the Installer's checks) before spawning — a .cmd/.bat shim (e.g. an npm-installed claude) is launched through cmd.exe /c since UseShellExecute = false can't exec a shim directly; a resolved .exe starts exactly as before. Throws if nothing resolves. (Not yet on main — see Environment Checks in ClaudeDo.Installer/CLAUDE.md.)
  • ClaudeArgsBuilder — --model, --effort, --max-turns, --append-system-prompt, --agents, --json-schema, --resume
  • StreamAnalyzer — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
  • WorktreeManager — worktrees on <typ>/<nummer>-<slug> branches, same leaf as the folder (NameFor, slug capped at 40 chars, umlauts transliterated). Typ from BranchTypeFor (feat→feature, fix/refactor/docs/test/chore as-is, anything else chore). Nummer = ticket id (own TicketRef or the parent's), else the task number — in a company repo (CompanyRepo, origin matches company_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, else list_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; BuildMerge is the merge-commit variant ({commitType}(slug): merge title + trailer). Every merge caller passes a blank commit message on purpose — TaskMergeService fills in BuildMerge from 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):

  • TaskStarted
  • TaskFinished
  • TaskMessage
  • WorktreeUpdated
  • TaskUpdated
  • ListUpdated
  • WorkerLog
  • PrimeFired
  • PrepStarted
  • PrepLine
  • PrepFinished
  • OperationProgress (generic (opKey, phase, current, total) channel; merge phases are its first producer)
  • PlanningMergeStarted
  • PlanningSubtaskMerged
  • PlanningMergeConflict
  • PlanningMergeAborted (planningTaskId, reason — every call site passes a human-readable reason now, from a real merge failure's ErrorMessage to a plain "Merge aborted."; the UI flashes it via FlashFooterError)
  • PlanningCompleted
  • RefineStarted
  • RefineFinished
  • UsageUpdated

WorkerLog carries two sources: hand-curated business events (_broadcaster.WorkerLog(...) in TaskRunner/TaskMergeService/TaskResetService) and every Serilog Warn/Error, re-broadcast by BroadcastLogSink (deduped within a 120 s per-message window; SignalR plumbing source-contexts filtered to avoid feedback loops). The sink also buffers all levels into LogRingBuffer for GetRecentLogs.

Findings store

Each list's working directory gets a .claudedo/ folder: one markdown file per finding under traps/, plus an INDEX.md that FindingsStore rebuilds from disk on every save (so findings deleted or renamed by hand self-heal). Findings are traps and invariants only — lasting, non-obvious, behaviour-changing. A fixed bug is git history, not a finding.

  • Written via one save_finding tool registered on both MCP surfaces: External/FindingsMcpTools (needs a list argument; refuses rather than guessing when several lists qualify) and Runner/TaskRunFindingsMcpTools (resolves the list from TaskRunMcpContextAccessor). Same slug overwrites. Both go through FindingsStoreLocator, which always resolves to the list's WorkingDir — the main checkout, never a worktree copy, or parallel runs would collide on INDEX.md.
  • Read with plain Read; there is deliberately no read tool. TaskRunner.BuildFindingsPointer adds one system-prompt layer naming the index, and only when the index file exists.
  • ListEntity.FindingsTracked decides whether the folder is committed. When false, FindingsStore writes /.claudedo/ to .git/info/exclude via GitExcludeWriter — per-clone, no tracked file touched.
  • INDEX.md is read by every run, so it must stay short: FindingsStore.WarnThreshold (80) flips nearCapacity in the tool result as a prune signal.

Config

~/.claudeDo/worker.config.json:

  • db_path, sandbox_root, log_root
  • worktree_root_strategy (sibling | central), central_worktree_root
  • queue_backstop_interval_ms (30000) — also the gate/throttle recovery timer
  • company_remote_pattern (/_git/) — Teilstring der origin-URL, an dem CompanyRepo ein 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, hub GetClaudeBin/SetClaudeBin; ticket_api_base_url: Settings → Ticketsystem, hub GetTicketSettings/SetTicketApiBaseUrl). Both edit the DI-singleton WorkerConfig in place and persist via their own SaveKey call (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 with Retry-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 entire Online/ stack is not registered), api_base_url (must be HTTPS or loopback, validated at startup), poll_interval_seconds (60), zitadel.authority/client_id/scopes. The refresh token is not in this file — DPAPI-encrypted at ~/.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, hub ImportTickets) — per-list, manual only (no polling): pulls the linked project's board (ListConfigEntity.TicketProjectId), keeps tickets whose StatusId is 1 (Offen) or 2 (InBearbeitung) and that are assigned to the PAT owner, skips any TicketRef already present on the list, and creates one Idle task per remainder via TaskRepository.AddAsync (so it gets a Number like every other task). Already-imported tickets are never updated — a re-import would overwrite task notes. ⚠️ The assignee lives in ticketAssignees[], never in the flat assigneeName — 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. personInChargeName is the wrong source too: it holds the login on some rows and the display name ("Mika Kuns") on others, while ticketAssignees[].assigneeName matches /pat/me's userName format exactly.
  • Cross-project import (Prime ImportTickets, MCP get_ticket_import_candidates / import_ticket) — TicketImportService.GetCandidatesAsync/ImportItemAsync sweep sidebar → every project board. Tickets match on ticketAssignees, tasks (tasks[]) on ticketTaskAssignees; board tasks with hasChildren are skipped. Dedupe on TicketRef is across all lists (also in the per-list ImportAsync). import_ticket without list uses AppSettings.TicketFallbackListId (written only via SetTicketFallbackListIdAsync / hub SetTicketFallbackList, never by UpdateAsync); with no fallback set the item is skipped.
  • Write-back (TicketStatusSync) hangs off TaskStateService.NotifyAsync — the one place every status transition passes through. Mapping: Running/WaitingForReview → ticket status 2 InBearbeitung, Done → 3 Fertig, everything else writes nothing (1 Offen is never written back — it's the entry state). A task without a TicketRef, or a TicketRef not shaped bandel:<id>, is a no-op before any DB read. Per-process de-dupe (taskId → last-written status) skips the redundant Running → WaitingForReview PATCH. Never throws — a ticket-system failure logs a warning and does not block the status transition or the queue.
  • BandelTicketClient wraps 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 from BandelEnvelope<T> and a non-2xx/success:false throws TicketApiException with a readable message. The first four are live-verified (2026-09-01) against http://192.168.45.161. ⚠️ PATCH /api/Ticket/{id}/status needs both statusId and statusName — omitting the name is a 400 ("The StatusName field is required.") and no status is written. The name is not validated against the id (statusId wins, any non-empty string passes), but it must be there; TicketStatusMap.NameOf supplies the canonical name. ⚠️ The base URL must be the IP — bandelapis.fb-tuning.local still refuses the connection.

Notes

  • The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
  • --permission-mode auto by default; legacy bypassPermissions settings map to auto at dispatch time. acceptEdits, plan, default pass through unchanged.
  • Task worktree branches follow <typ>/<nummer>-<slug> (see WorktreeManager); planning worktrees stay claudedo/planning/{guid} (their path is re-derived from the id on resume). UntrackedWorktreeRecovery therefore detects task worktrees by path (.claudedo-worktrees or the central root, minus planning/), not by branch prefix.
  • Both MCP servers run Stateless = true (Program.cs). No Mcp-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's RegisterMcpStep) already use type: "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/ProgressReporter is the one implementation (elapsed-time reporting via RunAsync, per-item i/n via ReportItem) — thread an IProgress<ProgressNotificationValue>? progress = null parameter through instead of writing another polling loop.