Foundation for long-running-operation feedback (P0-1). OperationStatus tracks
IsRunning/ShowIndicator(300ms grace)/Elapsed/IsStalled(60s since last Report)
via an injectable TimeProvider, no static timer. OperationIndicator binds a
status to the shared spinner style. Pre-provisions ops.* locale keys for
groups A and C; no ViewModel wired up yet.
Two chokepoints per P0-2: WorkerClient.InvokeTimedAsync(<T>) wraps all 63
_hub.InvokeAsync call sites (plus TryInvokeAsync), and Stopwatch blocks
around the Island VMs' bulk DB load/write paths (list load, task list load,
reorders, batch delete, reconcile tick, count refresh). Single-row reads
stay untouched per the plan's rule of thumb. No display, no behavior change.
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.
ClassifyItems() layers ApplyChainGrouping on top of partitioning, which
writes ShowAsChainMember/ChainStep/ChainAfterLabel on every row.
ClearCompletedAsync only needed the completed-section count/rows, so it
got that mutation as an unwanted side effect of counting.
Split the pure overdue/open/completed partition into PartitionItems();
ClassifyItems() now composes it with the chain-grouping mutation for
Regroup's actual render path. ClearCompletedAsync uses PartitionItems
directly. Regroup's behavior is unchanged.
The 4s reconcile tick called Apply(), which did Rows.Clear() + refill on
every tick -- resetting the user's scroll position and selection while
they're mid-read, exactly what the overlay exists to avoid.
Reconcile granularly instead: find the overlap between the previous and
latest snapshot (the ring buffer is append-mostly, evicting from the
front as entries age out of the 30-min window), trim rows for evicted
entries off the tail, and insert new entries at the head. A tick with no
change now leaves Rows untouched. The WarnErrorOnly toggle still goes
through the full Apply() rebuild, unchanged.
Padding on RowsListBox mirrored the ScrollViewer.Padding-excluded-from-
Extent trap fixed elsewhere today, so move the 10,4 inset onto the
VirtualizingStackPanel's Margin instead, matching the other scrollables.
Headless measurement showed Avalonia's Fluent ListBox template actually
forwards Padding onto PART_ItemsPresenter's margin already (content
side), so this particular control was never affected -- both variants
produce an identical Extent and a fully visible last row at max scroll.
Kept the change for consistency and corrected the CLAUDE.md gotcha to
reflect the verified behavior instead of assuming the same trap applied.
## 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.
PromptFileRecovery's orphan-quarantine log only reported a bare count, so a customized
merge-helper-execute.md silently stopped applying with no pointer to where it went. Log each
quarantined file's destination path, and for merge-helper-execute.md specifically call out that
it was split into merge-helper-wait.md and merge-helper-merge.md with no content migration.
Each handoff tile is a live claude process with the full mcp__claudedo__* surface; leaving the
outgoing phase's pane open leaked one process per phase. Close it via CloseConPtySession before
opening the next phase's tile, restoring the one-pane-per-TaskId invariant so
OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can go back to FirstOrDefault.
HandoffMcpTools.HandoffListHandler only validated the phase name and
broadcast it -- nothing bounded how many times a ConPTY session could
hand off wait<->merge, so a model that skimmed past the prompt's
"final round" line could loop indefinitely. Add HandoffRoundTracker,
an in-memory per-handler-task counter (a list-handler run always
creates a fresh handler task, so no reset logic is needed): past 4
handoffs (two full wait/merge cycles) a non-final nextPhase is
coerced to its "_final" counterpart, and any handoff after a
merge_final round for that task is rejected outright.
From the 2026-08-11 unpushed-commit review (Low). Both in `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`. **Visual verification by the user is required — do not claim either is fixed without a screenshot; list both as open visual checks in the result.**
## A) Chain step badge is drawn under the task card
Commit `eb66ae7` moved `Border.chain-step-badge` from column 0 into column 1 with `Margin
ClaudeDo-Task: 9ec7d4ea-272b-4645-8da4-41ff3621f2f0
The 4s reconcile tick was added to three long-lived surfaces. On two of
them it reloads by rebuilding every row instance, which silently threw
away whatever the user had built up since the overlay opened; on the
third it healed a row's data but left it in the wrong section.
- WorktreesOverview: LoadAsync clears Rows, SelectedCount, ConflictRows
and BatchProgress, so batch-merge ticks, the highlighted row and a
finished batch's outcome badges were wiped every four seconds --
assembling a multi-worktree selection was impossible. Carry that state
across the reload, keyed by task id, and re-point SelectedRow at the
fresh instance (or clear it when the worktree is gone).
- MergeHelperSelection: the remember/restore had no re-entrancy guard, so
a second tick entering between the first one's reload and its restore
snapshotted rows already back at IsTickedByDefault and wrote that
default back, re-ticking what the user had unticked. One tick at a time,
and hold the row instances instead of a value snapshot so a tick landed
during the await survives.
- TasksIsland: the tick deliberately never called Regroup because Phase 2b
owned Rows in parallel. 2b has landed, so a healed task that went Done
stayed in the Open section under a stale count, and a healed depends-on
link never pulled its dependent under the chain head. Regroup when a
patch moved a grouping input, gated on a cheap key so an idle tick stays
free.
The indent column was hardcoded to 24px, so every row reserved the
gutter even with no child/chain relation. Restore Auto sizing with the
width back on the Border, which collapses to 0 while it is invisible.
Move the step badge into column 1 with a negative margin so its desired
width can never widen the Auto column (same on-screen position).
Reuses the existing 24px indent track (now gated on ShowAsChild OR
ShowAsChainMember) instead of a second mechanism, adds a round step-number
badge centered on the rail line, and a dimmed "after X" chip for chain
members whose head isn't in view or that render as a planning child.
Slice 1 of dependency-chain display: TaskRowViewModel gains DependsOnTaskId
plus the extrinsic ShowAsChainMember/ChainStep/ChainAfterLabel contract for
Slice 2's rail/badge rendering. Regroup's ClassifyItems walks each row's
DependsOnTaskId chain per section, pulls dependents directly under their
head regardless of SortOrder, and falls back to a flat row + label when the
head isn't in the same section (mirrors the ParentInView precedent).
Planning children never join a chain group - parent indent wins - and only
ever carry the label.
No AXAML changes; that's Slice 2.
Document task-number allocation in Data layer (TaskEntity.Number,
next_task_number counter, invariants, insert paths).
Update Worker docs to clarify TaskIdResolver wiring (#123 → GUID lookup),
Number in MCP payloads, and correct the 'Two hard conventions' statement
(only the first is test-enforced).
Bump external-mcp.md verified-against commit to 38af549 (Slice 4 merge)
and add new sections on task ID resolution and numbering.
Add Slice 4 visual verification items to open.md (row/detail number display,
worker-log messages).
Verified against:
- src/ClaudeDo.Data/TaskNumberAllocator.cs
- src/ClaudeDo.Data/Repositories/TaskRepository.cs (AddAsync line 20, CreateChildAsync line 276)
- src/ClaudeDo.Worker/External/TaskIdResolver.cs
- src/ClaudeDo.Worker/External/ExternalMcpService.cs (TaskDto/TaskRefDto DTOs)
- tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs (only first convention test-enforced)
Slice 4/5 of task-numbers: TaskRowViewModel.Number renders as a dimmed
"#123" before the row title; DetailsIslandViewModel.TaskIdBadge now
shows "#123" instead of the unusable "#T<guid-prefix>" handle; and the
curated WorkerLog business events in TaskRunner, TaskMergeService, and
TaskResetService prefix their quoted title with "#<Number>".
TaskIdResolver resolves a #123/bare-123 taskId parameter to its GUID
before any lookup, across every External/ MCP tool that takes a task
id, including the batch tools' id arrays (via delegation to the
already-resolving single-entity methods) and update_task's
dependsOnTaskId (empty string still passes through unchanged as the
clear-link sentinel). An unknown number throws a clear error instead
of a silent null. McpToolDocs.TaskNumberHint tells the agent to refer
to tasks as #<number> when reporting to the user, added to the
description of get_task, list_tasks, add_task, update_task_status and
review_task.
Adds Number alongside every task id in External/'s DTOs -- the two
central mappers (ToDto/ToRefDto -> TaskDto/TaskRefDto) plus every
DTO that carries a bare task id and bypasses them (batch results,
queue state, wait-for-change, config, attachments, handoff,
lifecycle, merge-preview-set, worktree list). Input resolution
(#123 as an argument) stays for slice 3.
The task-numbers slice added a UNIQUE index on tasks.number. Ui.Tests seed
TaskEntity rows directly instead of going through TaskRepository, so every row
kept the default Number = 0 and 31 tests failed with
'UNIQUE constraint failed: tasks.number'.
TaskEntity.Number is a global, monotonically increasing, never-reused
integer (displayed as #123), allocated from AppSettingsEntity.NextTaskNumber
via a single UPDATE...RETURNING statement rather than MAX(number)+1, which
would reissue a deleted task's number. Both insert paths (TaskRepository.
AddAsync and CreateChildAsync) route through the new TaskNumberAllocator,
with a bounded retry on a unique-index collision. One migration adds the
columns, backfills existing rows in creation order, and creates the unique
index afterwards. Data-layer only; MCP/UI wiring is later slices.
HandoffRequested now carries nextPhase end to end (IWorkerClient ->
WorkerClient -> MissionControlViewModel -> GetMergeHelperHandoffLaunchSpecAsync),
and the outgoing tile is left open on handoff instead of being closed --
lookups that need the active pane for a task now use LastOrDefault since a
handler task's ConPtySessions can hold more than one pane.
Threads a nextPhase parameter (wait/merge/wait_final/merge_final, validated
by the new MergeHelperPhase) from handoff_list_handler through
HubBroadcaster/WorkerHub into InteractiveLaunchSpecService, which now picks
the next session's system prompt (MergeHelperWait/MergeHelperMerge) and
model (HandlerWaitAlias/HandlerMergeAlias) from it instead of hardcoding the
old two-phase Execute prompt -- this also fixes a build break left by the
prior prompt-split task, which removed PromptKind.MergeHelperExecute without
updating its only caller.
Also sets --model/--effort/--permission-mode explicitly for every list-handler
session (Triage included) via PermissionModeResolver instead of inheriting the
CLI's ambient model and hardcoding "auto", and adds Task to the merge-helper
allowlist so the Merge phase can delegate diff reviews to subagents.
Avalonia 12 leaves ScrollViewer.Padding out of the scroll Extent, so at
maximum offset the content still overhangs the viewport by the padding
height — the last lines were cut off and unreachable even after
ScrollToEnd(). Move the inset onto the content (Margin on the inner
ItemsControl/StackPanel) and leave the ScrollViewer padding-free.
Affects the work console Output/Git/Session tabs, the reusable session
terminal (task + prep log) and the log visualizer.
Adds HandlerTriageAlias/HandlerWaitAlias/HandlerMergeAlias to ModelRegistry and
replaces PromptKind.MergeHelperExecute with MergeHelperWait (phase 3 only) and
MergeHelperMerge (phases 4-5), so the list handler can run as three
cost-scoped sessions instead of two. Triage's Phase 2 now checks/sets a wide
verify command via get_list_config/set_list_config once per run. Merge now
merges before reruns, delegates diff review to a sonnet subagent, rejects
0-file diffs, tracks the merged-but-not-Done verify-gate outcome, and caps
reruns at one per task via handoff_list_handler's new nextPhase parameter.
InteractiveLaunchSpecService.cs still references the removed
PromptKind.MergeHelperExecute and fails to build -- wiring the Worker/UI side
onto the new roles is a follow-up task.
SetListConfig previously could only pass VerifyCommand through unchanged;
only the UI hub could set it. Add an optional verifyCommand parameter with
matching clear/merge/create-vs-delete handling, and split the list result
into ListConfigDto so the task-only SetTaskConfig/GetTaskConfig DTOs stay
untouched. Also corrects Worker/CLAUDE.md's claim that tasks can override
verify_command — it's list-only.
Shorten the review-prompt placeholder and localize it (session.reviewFeedbackPlaceholder,
en+de) instead of a hardcoded string that overran the narrow column. Switch the growing
TextBox's VerticalContentAlignment from Center to Top so multi-line input grows downward
instead of being clipped top/bottom, add an auto scrollbar past MaxHeight, and top-align
the prompt glyph and Resume button to match.
Cards touched with Margin=0. Add margin on the inner task-row border
instead of the ListBoxItem style, so the drag/drop hit-test container
still covers the row and the drop-hint doesn't flicker between items.
A list working_dir stored as "C:\Dev\Tests\StaplerTracking\" broke every consumer
that puts it on a Windows command line: argv rules read \" as an escaped quote, so
the token never closes. "Open in terminal" passed wt.exe a starting directory of
C:\Dev\Tests\StaplerTracking" and it failed with 0x8007010b; the same data had
already corrupted the ConPTY list handler's arg list in August.
Paths.TrimTrailingSeparator is now the single helper (replacing the copies in
InteractiveLaunchSpecService and ClaudeHelpLauncher) and ListRepository applies it
on Add/Update, which covers every writer: UI create, repo import, hub UpdateList,
and MCP CreateList/UpdateList. OpenInTerminal also switches to ArgumentList so its
quoting is correct regardless of what's stored.
AddScopeOverlapFields, AddTaskDependency and AddFailureReason were each
generated off the same parent migration, so none of their Designer
snapshots knows the others' columns. AddTaskDependency calls AddForeignKey,
which on SQLite forces a full `tasks` table rebuild generated from that
migration's own frozen snapshot -- silently recreating the table without
`scope_globs`.
Result: the Worker died at startup with "no such column: t.scope_globs"
(Hosting failed to start), so it never bound the SignalR port and the App
could not connect. Fresh installs were affected too, not just existing DBs.
Add the missing ScopeGlobs property to the stale Designer snapshot so the
rebuild preserves the column. Verified by migrating a fresh temp DB and
diffing pragma_table_info('tasks') against the model snapshot: 39/39
columns.
Note: the test suite cannot catch this class of bug -- every fixture uses
Database.EnsureCreated(), which builds the schema from the model and never
applies the migration chain.
Phase 2b-1 moved the group headers from {loc:Tr} bindings in TasksIslandView
into HeaderRow.Label, resolved once in Regroup(). RefreshLocalizedText only
updated CompletedHeader and the task rows, so after switching the language the
three section headers kept the old one. Re-run Regroup() there.
Not unit-tested: the only seam is the global static Loc.Current, and mutating
it would bleed into test classes running in parallel.
Replace the three ItemsControl sections (Overdue/Open/Completed) with a single
flat Rows collection (HeaderRow | TaskRowViewModel) bound to a ListBox +
VirtualizingStackPanel, so the realized container count stays bounded instead
of growing with the list (measured: ~7-8 realized containers at any scroll
position with 1000 source rows). Group headers become regular row entries,
each section is simply omitted from Rows when empty, and the Completed
section's Clear-completed action moves onto the header row itself.
SectionFor/ReorderAsync/FindNextInSameSection now read a row's section off
the nearest preceding HeaderRow in Rows instead of three separate bound
collections, and ScrollSelectedIntoView goes through ListBox.ScrollIntoView so
it still works for rows outside the realized window. Drag/reorder behavior,
grouping/counts, and the Clear-completed button are unchanged.
Auto-scroll-while-dragging and the cross-section reorder bug are explicitly
out of scope (follow-up tasks).
Two follow-ups to the Phase 3 reconcile tick:
- LogVisualizer and WorktreesOverview only stopped their timer in the Close
command, but ShowLogVisualizerAsync/ShowWorktreesOverviewAsync had no
dlg.Closed fallback (unlike the MergeHelper picker). An Alt+F4 or owner
close left a 4s timer polling the worker / re-running LoadAsync on an
orphaned VM, accumulating one per open. Extracted StopReconcileTick on both
VMs and wired it from Closed.
- The tick scanned Items linearly per id; at the 500-row cap that is ~250k
string comparisons every few seconds. Index the rows once instead.
TasksIslandViewModel now runs a periodic tick that diffs the flat Items
collection against SQLite and patches properties in place (capped at 500
rows, sharing the Phase 1 delta sequence guard so it never overtakes a
fresher broadcast). Never rebuilds rows or triggers Regroup/LoadForList, so
it stays decoupled from the parallel Phase 2b virtualization work. The same
cadence now refreshes the long-lived Worktrees Overview, Log Visualizer, and
Merge Helper selection overlays; short-lived modals are untouched.
Two branches merged into main independently: one added `runVerify` to
PreviewMergeSet ahead of `cancellationToken`, the other added the subset
test that passed the token positionally. Neither conflicted textually, so
the mismatch only surfaced as CS1503 at test-compile time.
TaskRunner.ContinueAsync resolved a ClaudeRunConfig but never attached
a per-run MCP token/config or AllowedTools the way RunAsync does, so a
--resume continuation (e.g. reject-rerun with feedback) lost every
mcp__claudedo_run__* tool, including AskUser. Extracted the setup into
SetupMcpConfigAsync and call it from both paths, with matching cleanup
in ContinueAsync's finally block.
After a merge lands on the target branch, TaskMergeService now rebases every
other WaitingForReview task's active worktree onto the new tip -- but only
when that branch actually touches a file the merge just changed, computed via
GitService.GetChangedFileNamesAsync (both the merge's landed files and each
candidate's own diff). A branch merely behind is left alone. On conflict or
any other failure the rebase is aborted and the branch is left exactly as it
was, with a WorkerLog warning naming the task; a successful rebase updates the
worktree's recorded BaseCommit/HeadCommit and broadcasts WorktreeUpdated.
Queuing (update_task_status, batch_update_task_status) and run_task_now
now surface a non-blocking baseDirty warning (separate modified/untracked
counts) when the list's working dir has uncommitted changes at enqueue
time, since a new worktree forks from the commit tip and silently misses
them. BaseDirtyChecker caches per working dir for a few seconds so a
batch queue over many tasks in one list only shells out to git once. The
UI surfaces the same warning via the footer error strip on queue actions.
A MinVer dev build on main computes a prerelease of its own guessed next
version (e.g. 2.9.1-alpha.0.14 after tag v2.9.0), which already sorts
above the last tag by numeric core, so the update banner correctly
stays quiet there. But the reduction to a bare numeric core meant a
genuine new release landing at exactly that guessed version (v2.9.1
real) compared equal to the still-running prerelease and the banner
never fired. A prerelease now only loses to a real release of the same
core, not to another prerelease of the same core.
Verified against the live repo: latest published tag is v2.9.0; a
Release build of this worktree (14 commits ahead) reports
AssemblyInformationalVersion 2.9.1-alpha.0.14+<sha>.