Per-merge verify made an N-task batch pay N x ~7 min, each run testing
the same moving main. review_task/merge_task/continue_merge now take
skipVerify: the merge lands but the gate AND the Done transition are
deferred (status merged_verify_pending, task stays WaitingForReview).
The new verify_merges tool runs the list verify command once for the
explicitly listed tasks and promotes them to Done on success - explicit
ids so the handler own submitted task and parked verify_failed tasks
are never swept up; an Active worktree is refused per entry.
PlanningMergeOrchestrator threads the flag through the unit merge (no
more per-child verify) and skips FinalizeParentDoneAsync when verifies
were deferred. The merge-helper Merge prompt approves with
skipVerify=true and calls verify_merges once after the last merge. UI
approve is unchanged; the no-Done-without-green-verify invariant stays
server-enforced.
IBaseDirtyChecker, IInteractiveLaunchSpecService and the LaunchSpec wrapper
had one implementation and one caller each; ProcessRunnerAdapter existed only
to give a static class an interface, and InstallArtifactLocator used
inheritance for two constructor arguments. The external MCP container now
shares its singletons through a Share<T> helper instead of 17 near-identical
registrations.
WorkerHub and IWorkerClient each carried their own copy of every record and
had already drifted — MergePreviewDto lost its verify fields on the client
side, two records disagreed on their name. They now live in Data/Wire.cs and
reach both sides via a <Using> item, so a hub signature change is a compile
error instead of a silently dropped JSON field.
MarkDoneAsync was Idle-only, so a finished task (typically a list-handler
run with no worktree to merge) could not be ticked off. Guard on Running
instead and cover the other statuses with tests.
Nine interfaces had exactly one implementation and no test double — they existed
only to be named twice in a DI registration: IFindingsStore, IFindingsStoreLocator,
IPrimeScheduleSignal, IRefineRunner, IWeekReportService, IMergeCoordinator,
IMissionControlPane, IOnlineLoginService, ITaskListFilter. Consumers now depend on
the concrete type; the DTO records that shared those files moved next to their
implementation. IInteractiveLaunchSpecService stays — it carries 54 lines of
contract documentation, which is not ceremony.
IMergeCoordinator in particular had a redundant null object: MergeCoordinator with
a null Handler already no-ops, and every test used the real class with Handler set.
Filtering/ collapses from 8 files to 1. ITaskListFilter and TaskListFilterBase were
a double abstraction over four predicates, with MatchesAsContext => false declared
in both. SmartFlagFilter also compiled its expression twice (its own _flag plus the
inherited Matches cache) — it now uses the cache.
StaticTokenAuthProvider was in src but production uses ZitadelAuthProvider; it is
a test double, so it moves to the test project. Its own test goes away with it.
The modal deliberately shows only a slice of the analytics; this hands off
to TokenTracker's own local dashboard for the rest. The worker starts
`tokentracker serve` on demand and returns the URL, the UI opens the browser.
Three things the spawn has to get right: port 7680 is not free on Windows
(Delivery Optimization holds [::]:7680) and serve does not fall back, so we
scan 7680-7689 with a dual-stack bind probe; --no-open because the CLI would
open the browser before the server answers; and the child is a cmd.exe shim,
so shutdown kills the process tree. --no-sync keeps our no-cloud-sync rule.
Zwei liegengebliebene Consumer aus den letzten beiden Commits:
- TaskRunner klassifiziert seit 07dd7570 "usage_limit", aber weder
TaskRowViewModel.FailureReasonLabel noch vm.failureReason (de/en) noch
die get_task-Tool-Beschreibung kannten den Wert — die UI zeigte
"Grund unbekannt", das MCP-Doc listete weiterhin max_turns|timeout|error.
- TaskRowViewModel.CanOpenWorktree prüfte nur auf einen nicht-leeren
String. Die Zeile behält den Path eines gemergten/verworfenen Worktrees,
also war der Menüpunkt aktiv und Process.Start warf in den Footer.
Jetzt zusätzlich Directory.Exists — dieselbe Prüfung, die
WorktreesOverviewModalViewModel und MergeSectionViewModel schon machen.
OverrideSlotService hält genau einen Slot, und ein Usage-Limit stoppt
naturgemäß alle laufenden Tasks gleichzeitig — "mehr Kandidaten als
Kapazität" ist also der Normalfall. Der Coordinator stempelte
UsageLimitAutoContinuedAt aber VOR dem ContinueTask (der Marker ist die
Dedupe-Guard), sodass Kandidat 2..n den Marker bekamen, deren
ContinueTask mit "override slot busy" in den catch flog und sie danach
dauerhaft aus GetUsageLimitAutoContinueCandidatesAsync ausgeschlossen
waren — ein Continue, der nie lief.
- Vorab-Check auf CurrentSlot: bei belegtem Slot bricht der Tick ab,
statt die restlichen Kandidaten zu verbrennen.
- Neuer TaskRepository.ReleaseUsageLimitAutoContinueClaimAsync gibt den
Claim zurück, wenn ContinueTask wirft; danach return, der 30s-Backstop
holt den Rest im nächsten Tick.
- Regressionstest mit zwei Kandidaten, deterministisch über eine
TaskCompletionSource im FakeClaudeProcess (StartInSlot setzt _slot
synchron unter dem Lock, bevor die Arbeit startet).
Klassifiziert einen echten Usage-Limit-Abbruch als eigene FailureReason
"usage_limit" (TaskRunner.ClassifyFailureReason: nur bei terminal_reason
"api_error" plus einem Limit-Muster im gerenderten Fehlertext, nicht an
Status==Failed allein). Neuer Toggle AutoContinueOnUsageLimit (app_settings,
Default aus) unter Settings → General → "Usage limit stop":
- UsageLimitAutoContinueCoordinator feuert pro Task genau einmal ContinueTask
über OverrideSlotService, sobald das 5h-Fenster (UsageState.Snapshot.FiveHour
.ResetsAt) tatsächlich zurückgesetzt ist; ein persistenter Marker
(TaskEntity.UsageLimitAutoContinuedAt) verhindert einen zweiten Anlauf bei
einem erneuten Limit-Treffer.
- QueueService schedult zusätzlich einen exakten Wake-Timer auf den
Reset-Zeitpunkt, statt nur auf den 30s-Backstop zu warten.
- Fail-open durchgängig: kein Snapshot/keine Reset-Zeit → kein Timer, kein
Continue, kein Throw. Toggle aus ändert das heutige Verhalten nicht.
Migration AddUsageLimitAutoContinue fügt beide Spalten hinzu; die von
`dotnet ef migrations add` mitgescaffoldete leere UpdateData auf app_settings
(columns/values: []) erzeugte ungültiges SQL ("near WHERE") und wurde entfernt
— TaskNumberMigrationTests deckte das über den vollen Migrate()-Pfad auf.
Neither the initial nor the handoff kickoff ever told a list-handler
session its own handler task id, so handoff_list_handler(taskId, ...)
was unrenderable -- the handoff chain broke exactly where it was needed
(#200/#201 on 2026-08-21).
Add {handlerTaskId} to both MergeHelperInitialDefault and
MergeHelperHandoffDefault, thread a handlerTaskId parameter through
BuildForMergeHelperAsync (interface, WorkerHub.GetMergeHelperLaunchSpec,
IWorkerClient/WorkerClient, and the MissionControlViewModel call site,
which already had the id from CreateMergeHelperTaskAsync but never
passed it on), and render it in BuildForMergeHelperHandoffAsync from the
taskId parameter it already receives.
RenderTemplate leaves unknown/missing tokens untouched, so a
user-edited override without the new token still renders fine -- no
forced migration for override users.
Group 1 of the task-row context menu gets a fourth gated entry, right
after "Open interactive session": it opens the task's worktree folder
via Process.Start/UseShellExecute, mirroring ListsIslandViewModel's
OpenInExplorer. Missing worktree greys the item out with a tooltip
reason instead of hiding it, keeping Group 1's fixed length. Failures
report through TasksIslandViewModel.ErrorReported (footer strip), not
a silent catch.
Teil A: update_task bekommt einen optionalen isManual-Parameter (null =
unveraendert); TaskDto/TaskRefDto spiegeln IsManual zurueck.
Teil B: neuer submit_task_for_review MCP-Tool (LifecycleMcpTools) laesst
einen Handler-Task selbst auf WaitingForReview gehen -- fuer einen
worktree-losen Task wird HandlerHeadCommit vom aktuellen HEAD gestempelt,
sonst werden offene Aenderungen committet. Die Submit-Logik ist aus
WorkerHub.SubmitTaskForReview in InteractiveReviewSubmissionService
extrahiert; der Hub ruft sie jetzt nur noch auf. Der Merge-Prompt-Default
weist die Endzweige (merge ohne Rerun, merge_final) an, den eigenen
Handler-Task nach der Summary einzureichen.
Eine interaktive ("Quick") Session soll auf Pfaden, die kein Git-Repo sind, keinen Worktree erzwingen, sondern direkt im ausgewählten Ordner starten. Der User hat entschieden: BEIDE Einstiegspunkte prüfen — den task-gebundenen Pfad fixen, den Ad-hoc-Pfad verifizieren.
## Pfad 1 (der eigentliche Fix) — task-gebundene Session
- Einstieg: Kontextmenü der Task-Zeile "Open interactive session" — src/
ClaudeDo-Task: 15e126d564874973be2a5bbe7d796646
The six Lifecycle/*Recovery hosted services now broadcast one
OperationProgress("startup-recovery", <phase>, current, total) message each
after they finish, instead of leaving the UI on a bare "connecting" text
during worker startup. IslandsShellViewModel subscribes and swaps in
"Recovering... (i/n)" (existing ops.worker.startupRecovery key, no locale
changes) while Worker.IsReconnecting is true, and clears it once actually
connected so a later transient reconnect doesn't replay stale text.
OperationProgress broadcasts to Clients.All with no replay-on-connect, so a
UI that hasn't finished its SignalR handshake yet can miss some or all of
these messages and simply keep showing "connecting" as before -- accepted
rather than adding a cached-state + reconnect-replay path (mirroring
RefreshExternalMergeConflictsAsync) for what is a fast, best-effort,
local-only startup sweep with no UI-visible failure mode beyond that.
Fills the silent gap between Queued and the first agent output: WorktreeManager
now broadcasts a "creating_worktree" phase (before the initial git worktree add
and again for the self-heal retry section) through the existing OperationProgress
channel, and the task row shows it via the pre-existing but unused
ops.worker.creatingWorktree locale key until the next entity refresh clears it.
Confirmed the 2026-08-07 triage finding still holds: TaskRunner already
broadcasts WorktreeUpdated right after WorktreeManager.CreateAsync
(TaskRunner.cs:342-346, :501) -- no second broadcast added there.
RebaseOthersAfterMergeAsync now broadcasts a "rebasing" phase with i/n over the
WaitingForReview branches it checks, so the merge/continue_merge callers stop
showing the stalled "Merging…" phase while the best-effort rebase loop runs
(rebase still runs before the verify gate; a failed rebase still leaves the
merge itself successful). WorktreeMaintenanceService gained an optional
HubBroadcaster to report the same i/n shape per worktree during
cleanup/reset, with no new UI surface (deliberately out of scope). Both
review-action viewmodels now also listen on OperationProgressEvent (which
carries the total that the elapsed-seconds-only MergeProgressEvent drops) to
render "Rebasing other worktrees… (i/n)".
The task-list done toggle (both islands) and RemoveFromQueue wrote TaskEntity.Status
directly via EF, bypassing TaskStateService: no TaskUpdated broadcast, no guard against
a concurrent picker claim (lost update), and no status-based filter. Added guarded
MarkDoneAsync/UnmarkDoneAsync/DequeueToIdleAsync transitions plus matching hub methods
(SetTaskDone/UnsetTaskDone/DequeueTask) and IWorkerClient wrappers; the three UI call
sites now route through the hub with optimistic-then-revert row updates and
ErrorReported on failure. RemoveFromQueueAsync dequeues each queued child individually
through the same guarded path instead of cascading via a raw EF update.
Also closes two hub guard gaps: UpdateListConfig's delete branch now preserves a list's
SerializeOnFileOverlap flag instead of dropping it, and SubmitTaskForReview's Idle/Failed
status gate now runs before either mutation branch so a Done/Cancelled task can't get
committed or stamped and then rejected.
A child merge that came back blocked/verify_failed/untracked_collision during a
parent/children unit merge used to vanish: DrainAsync only logged it server-side,
PlanningMergeAborted carried no reason, and ApproveReview/review_task always
reported StatusMerged for a task with children regardless of the real outcome,
so a failed unit merge left the parent stuck with no visible error.
- PlanningMergeOrchestrator.StartAsync/ContinueAsync/DrainAsync now return a
PlanningMergeResult(Status, Reason) instead of void, and PlanningMergeAborted
carries that reason to the UI.
- WorkerHub.ApproveReview and ExternalMcpService.ReviewTask's approve branch
propagate the real status/reason for a parent with children instead of
hardcoding "merged" (or masking a non-conflict failure as "conflict").
- StartAsync now requires the parent to already be WaitingForReview for
improvement parents too, not just planning ones, so a stale caller can no
longer trigger a partial child merge.
- HasActiveMerge now also covers the window between the last child merging and
FinalizeParentDoneAsync completing, closing a gap where a concurrent Cancel
could race the parent's own approve-to-Done transition.
- IslandsShellViewModel.OnPlanningMergeAborted flashes the reason via
FlashFooterError instead of only clearing the external-merge banner.
TaskRunner.ContinueAsync: Claim, SeedAsync und SetupMcpConfigAsync liefen
vor dem try/catch (anders als RunAsync) - warf einer davon nach dem
Running-Claim, propagierte die Exception ungefangen bis zu
OverrideSlotService.RunContinueInSlotAsync, das nur loggt. Task blieb
Running. Fix: derselbe Aufbau wie RunAsync (Claim+Seed+MCP-Setup im
try, MarkFailed im catch, mcpToken/mcpConfigPath vor dem try auf null).
SetupMcpConfigAsync bekommt zusätzlich einen onTokenRegistered-Callback,
damit die äußere mcpToken-Variable den Token sofort nach dem Register
sieht - sonst hätte ein Fehler zwischen Register und Rückgabe (z.B.
File.WriteAllTextAsync) den Token in der TaskRunTokenRegistry geleakt
(betrifft denselben Aufruf in RunAsync mit, daher dort ebenfalls
verdrahtet - RunAsync-Struktur selbst unverändert).
QueueService.RunInSlotAsync: der Stop-Button (CancelTask) cancelt die
Slot-CTS direkt ohne TaskStateService-Schreibzugriff. Traf das die
Pre-Dispatch-DB-Reads, loggte der OCE-Catch nur und die vom Picker
bereits auf Running geclaimte Task blieb dort für immer hängen. Fix:
Status im Catch neu lesen und nur wenn er noch Running ist über
TaskStateService.CancelAsync auf Cancelled setzen - ein Ursprung, der
bereits selbst einen Terminalstatus geschrieben hat (z.B. CancelReview),
wird nicht überschrieben. Kommentar korrigiert.
TDD: neue Tests in ContinueAsyncExceptionTests (Seed-/MCP-Setup-Fehler
nach Claim -> Failed, kein Token-Leak) und QueueServiceSlotFailureTests
(Stop während Pre-Dispatch -> Cancelled statt Running; ein bereits
terminal geschriebener Status wird nicht gestompt) vorher rot, jetzt
grün. Worker.Tests: 1213/1213 grün, Worker baut in Release.
continue_merge and the parent/children unit-merge drain (PlanningMergeOrchestrator)
re-run the post-merge verify gate but never forwarded their IProgress token into it,
so a slow verify command on either path went silent past Claude Code's 300s MCP
idle-abort even though D1-D3 already fixed this for merge_task/review_task's
childless path. list_worktrees also gets elapsed-time progress: many tracked
worktrees means many concurrent git subprocess spawns.
Worker CLAUDE.md's existing progress rule now points at ProgressReporter as the
one implementation instead of a fresh polling loop.
Wraps the single-element git/worktree long-runners in ProgressReporter.RunAsync
(time-based, ExternalMcpService.ProgressReportInterval) and adds i/n reporting
to preview_merge_set's per-task loop, so these MCP calls survive Claude Code's
~300s idle-silence abort instead of leaving the caller with no signal that work
is still happening.
Each batch tool now sends an i/n progress ping via ProgressReporter.ReportItem
after processing every item, so a waiting agent doesn't see the MCP client's
300s idle-silence abort while the worker keeps looping. Covers BatchGetTasks,
BatchAddTasks, BatchUpdateTaskStatus, BatchCancelTasks, BatchDeleteTasks,
BatchSetMyDay, and BatchCleanupTaskWorktrees (the slowest of the seven, since
it does git work per task).
Pulls the MCP idle-timeout progress loop out of TaskMergeService into a
standalone ProgressReporter (Lifecycle namespace) shared by the verify
gate and preview-verify call sites, and adds a per-item i/n overload
(ReportItem) for upcoming batch progress in D2/D3.
DetailsIsland.BindAsync churn (~1900 binds/day, 91-96% cancelled) was not a
UI reactivity bug: OperationTiming.Shared is a static singleton hardcoded to
~/.todo-app/logs/operation-timing.ndjson, and Ui.Tests/Worker.Tests both
construct real DetailsIslandViewModel/TasksIslandViewModel instances that
call Shared.Record directly. Every dotnet test run appended 50-100 lines
straight into the live app's log — pid-burst analysis showed 13 distinct
test-run pids in the ":?" bucket (tests never pass a source) plus a
no-suffix bucket with no pid field at all (pre-dating the pid-per-line
feature). Real user-driven binds that day: 4.
Made Shared settable and added a [ModuleInitializer]-based TestSetup in
both test projects that redirects it to a per-process temp file before any
test runs.
Merge/verify phases now broadcast over a generic (opKey, phase, current,
total) Hub event instead of a merge-specific one, so future producers
(worktree cleanup, startup recovery, planning integration) can reuse it.
IWorkerClient.MergeProgressEvent stays as a thin forwarder for existing
consumers (elapsed seconds riding in the generic "current" slot).
The merge commit message was hand-rolled per caller ("Merge task: <title>",
"Merge <branch>", "Merge subtask") and ignored the task's commit type. Every
caller now passes a blank message and TaskMergeService fills in
CommitMessageBuilder.BuildMerge -> {commitType}(list-slug): merge <title> plus the
ClaudeDo-Task trailer; the merge modal prefills it from GetMergeTargets.
A merge whose list has a verify command holds the MergeTask call for minutes (5m46s
on this repo), during which the modal only disabled its button - no spinner, no
message, so a landed merge looked like a dead app. TaskMergeService now broadcasts
MergeProgress(taskId, phase, elapsedSeconds) for the merging and verifying phases
(re-reported every 30s) plus a WorkerLog line when verify starts; the modal shows a
spinner and the localized phase.
## Symptom (real aufgetreten, 2026-08-11)
Ein `review_task(decision="approve")` über MCP lief >5 Min. Claude Codes MCP-Client brach den Call ab mit:
> MCP server "claudedo" tool "review_task" sent no response or progress for 300s; aborting.
Ergebnis: der Merge war **schon gelaufen und comitted** (`0e12be4`, `mergeCommit` am Worktree gesetzt), aber der Task blieb auf `WaitingForReview` hängen —
ClaudeDo-Task: d8199f1f-3df3-447f-8de2-e7aca9ec5064
wait_for_task_change resolved #<number>/bare-number ids up front via TaskIdResolver, which
throws for an unknown number -- breaking the tool's own documented promise that an unknown
id reports status "NotFound" instead of failing the whole call. Resolve per id and fall back
to the original id on a resolution failure so CheckOnceAsync can still report it.
TaskNumberAllocator.AddWithNumberAsync indexed into the app_settings UPDATE...RETURNING result
without checking for an empty result, throwing on a missing singleton row; it also caught any
DbUpdateException as a number collision, burning up to 5 numbers on an unrelated failure (e.g.
FK violation) before the real error surfaced. Now recreates the missing row and only retries on
the actual unique-index collision (SQLite error 19 on tasks.number), rethrowing everything else
immediately.
Audited the other TaskIdResolver.ResolveAsync/ResolveManyAsync call sites (ExternalMcpService,
HandoffMcpTools, ConfigMcpTools, RunHistoryMcpTools, AttachmentMcpTools, LifecycleMcpTools,
BatchMcpTools): none of their tool descriptions promise a found/NotFound flag for the id itself
(BatchMcpTools.BatchGetTasks already handles this correctly via its own per-id try/catch;
PreviewMergeSet promises a per-task "error" field, not a found/NotFound flag; the rest are
single-id tools that already throw on a missing task downstream) -- left throwing behavior as-is.