Usability-Audit 2026-09-10 found three gaps versus the dependency-chain-display
spec: a Queued task blocked on an unmet task_dependencies edge rendered the same
chip as one about to run, a cross-list predecessor silently dropped the
"after #N" chip, and the detail pane showed nothing about a wait at all.
- TaskRowViewModel gains IsBlockedByDependency/DependencyWaitLabel (extrinsic,
assigned by TasksIslandViewModel like the existing chain fields); IsQueued/
IsWaiting/StatusChipClass/StatusLabel/StatusChipTooltip now account for it
alongside the existing BlockedByTaskId planning-chain link.
- TasksIslandViewModel.ApplyDependencyWaitState computes the blocked flag and
wait label per row; a new RefreshExternalDependencyCacheAsync backfills
Number/Title/Status for predecessors not loaded into the current list's
Items (reusing the already-open DbContext, no new hub method), so
BuildAfterLabel/blocking both resolve a listenfremder Vorgänger correctly.
- New "waiting" chip style (IslandStyles.axaml) and locale keys (en+de);
TaskHeaderBar shows a "Waiting on #N" line bound straight to the same row.
Tests: TasksIslandChainTests (blocked/unblocked chip, cross-list predecessor),
DetailsIslandDependencyWaitTests (new), TaskRowViewModelNotificationsTests
updated for the new notification set.
InteractiveTerminalViewModel.HasExitedAfterStartup now covers every
post-startup death that ExitedAfterStartup alone missed (crash, clean
exit, quiet exit inside the grace window) -- previously the tile just
stayed looking alive/thinking with zero signal. ConPtyPaneView binds a
new banner to it (message + Retry + Close); CanRetry simplifies to
"any HasExited state" since the same descriptor factory can relaunch
either kind of death. HandlerRestartCoordinator's worker-side
auto-restart path is untouched.
en.json and de.json each defined the checks object twice; JSON silently
kept the second block's values, so the first block's friendlier titles/hints
(e.g. "Permission Mode" over the raw "--permission-mode auto support" flag)
were dead text. Merged into one block per key, keeping the friendlier title
and the more actionable hint.
Added a Utf8JsonReader-based duplicate-top-level-key test, since
JsonDocument accepts duplicates without error and the existing parity test
can't see this failure mode either.
Neuer Eintrag im Worker-Menü der Titelleiste, der die Claude CLI über
das konfigurierte claude_bin (ExecutableResolver, inkl. Shim-Handling)
mit `claude update` aktualisiert. Ausgabe/Fehler landen im Footer-Strip
(FlashFooterSuccess/FlashFooterError), Menüpunkt ist während des Laufs
deaktiviert. 5-Minuten-Timeout tötet den Prozessbaum.
Data: folder delete keeps its lists (ON DELETE SET NULL), rename patches
name/icon without resetting sort order or collapse state.
Sidebar: grouping on load, folder badge as the sum of its children, both
move routes, and the persisted collapse state.
Drops the LoadAsync/AddRowAsync normalization of a dangling folder_id:
FK enforcement is on for every connection, so the row it guarded against
cannot exist, and ContainerOf already falls back to the top level.
Ctrl+V wurde von TerminalControl als rohes Control-Byte an das Kind
weitergegeben und tat nichts; jetzt wird es im Tunnel abgefangen und geht
ueber TerminalView.PasteAsync, damit Bracketed Paste greift und ein
mehrzeiliger Paste nicht auf der ersten Zeile abschickt. Gedroppte Dateien
werden als Pfade in den Composer getippt, ohne Enter.
Sechs Markdown-Sheets (git, Conventional Commits, Claude CLI, Claude-Config,
dotnet CLI, PowerShell) als EmbeddedResource, gerendert im modelosen, per
Topmost anpinnbaren CheatSheetsWindow. MarkdownView bekommt dafuer das Opt-in
CommandRows: `befehl` - beschreibung wird zu Mono-Chip + Copy-Button, jede
##-Sektion landet in einem Expander. Enthaelt ausserdem: Mission Control
wird beim Oeffnen aus dem Minimiert-Zustand geholt, nicht nur aktiviert.
Schalter, Zeitfenster und Handoff-Prozent lagen im Usage-Monitor und mussten
in SettingsModalViewModel nur load->save durchgeschleift werden, damit ein
Settings-Speichern sie nicht zurueckwirft. Jetzt gehoeren sie dem General-Tab,
der Monitor ist wieder nur Anzeige + Threshold-Drag, und "Jetzt aktualisieren"
sitzt als Icon in der Titelleiste (neuer ModalShell.HeaderActions-Slot).
Eine manuelle Liste (Erinnerungen) hat kein WorkingDir. BuildForTaskAsync
warf dafuer bisher "no working directory configured", die interaktive
Session liess sich also nicht oeffnen. Jetzt faellt der Pfad auf das
User-Verzeichnis zurueck; Session-Skills werden dort bewusst nicht
geseedet, weil das Ziel das echte ~/.claude/skills waere.
Ein "Abandon run"-Knopf an Handler-Panes fragt HandlerRoundState.Get(handlerTaskId)
nach den noch offenen (Queued/Running) Tasks des Laufs, bestaetigt mit Anzahl,
cancelt jeden ueber den bestehenden CancelTaskAsync-Pfad und schliesst danach die
Session -- ein Schritt statt Tile schliessen + Queue separat abbrechen. Ohne
offene Tasks (oder ohne Round-Eintrag) schliesst die Aktion direkt, ohne Dialog.
Nur an den beiden Handler-Panes sichtbar (IsListHandler), normale Task-/Ad-hoc-
Panes bleiben unveraendert.
CanRemoveFromQueue schloss IsWaiting (queued+blocked) faelschlich aus, sodass ein
Kettenglied in der Mitte nicht entfernbar war. Zusaetzlich unblockt
TaskStateService.DequeueToIdleAsync jetzt den Nachfolger eines dequeueten
Kettenglieds, sonst bliebe er dauerhaft auf einen nicht mehr laufenden
Vorgaenger blockiert.
BeginLoad's fire-and-forget LoadAsync now catches instead of leaving an
unobserved task exception, reports it via the new ErrorReported event
(wired into FlashFooterError like the other modal VMs), and keeps Save
locked so the failure can't clobber list_config with blank fields.
LoadTicketProjectsAsync's empty catch now reports too; behavior for the
dropdown itself is unchanged.
OpenListSettingsAsync/CreateListAsync used to await LoadAsync (name/agent
config/list_config x2/ticket settings/ticket projects, all serial) before
showing the window -- an unreachable ticket system alone could add a 10s
HTTP timeout to every open. LoadAsync now runs via BeginLoad (fire-and-forget,
mirrors UsageMonitorModalViewModel), the modal shows immediately and fields
fill in behind it.
GetListConfigAsync no longer runs twice per open -- AgentConfigEditorViewModel
exposes the row it already fetched (LastLoadedListConfig) instead of the
caller re-fetching it. Ticket-project loading is split into its own
fire-and-forget step so a slow/unreachable ticket system can't block the
rest of the fields, and its tri-state Save logic now also treats "never
finished loading" like "not available" -- leaving the stored
TicketProjectId link untouched instead of overwriting it with 0. Save/Delete
are gated (CanExecute) on the core load being done, so a save can't land
before the fields it writes are populated.
Not measured with OperationTiming against a live worker in this session (no
running app instance available) -- verified instead via 4 new unit tests
that assert GetListConfigAsync fires exactly once and that a hung/failing
ticket-projects call neither blocks LoadAsync's other fields nor corrupts
the saved TicketProjectId. Before/after OperationTiming numbers and a visual
pass are still open follow-ups.
feat(worker): Handler-Session vor dem Usage-Limit sauber uebergeben (#275); Konflikte mit start_list_handler aufgeloest, WorkerLog-Aufruf im Fire-and-forget-Pfad wieder abgesichert
Eine PowerUse-Handler-Session soll am 5h-Limit nicht mehr mitten im Merge
sterben, sondern vorher selbst sauber uebergeben:
- HandlerUsageGuard (neu) warnt die laufende Session genau einmal pro Runde,
sobald FiveHour.Utilization den Schwellwert PowerUseHandoffPct erreicht.
Die Warnung wird per HandlerUsageWarning-Broadcast an Mission Control
geschickt und dort in das Pane der Handler-TaskId getippt.
- handoff_list_handler broadcastet den Handoff dann nicht sofort, sondern parkt
ihn bis ResetsAt + 60 s; das Ergebnis traegt deferredUntil. Ohne ResetsAt
wird wie bisher sofort gesendet.
- Der geparkte Handoff liegt im HandlerRoundState (handler-rounds.json) und
wird vom Guard-Tick ausgeloest, also auch nach einem Worker-Neustart wieder
eingeplant bzw. sofort gesendet, wenn er ueberfaellig ist.
- Review-Befund aus #272: HandlerRestartCoordinator wartet nicht mehr selbst
bis ResetsAt, sondern parkt den Neustart im selben Slot. AutoRestartUsed wird
erst gesetzt, wenn der Broadcast wirklich rausging - ein Worker-Absturz
waehrend der Wartezeit laesst die Runde nicht mehr dauerhaft tot zurueck.
Zurueckgehaltener Handoff und Auto-Neustart teilen sich einen Slot, also
oeffnet nie beides dieselbe Runde. UsageLimitProofPct entfaellt zugunsten von
PowerUseHandoffPct.
- PtyTerminalSession.SendLineAsync schreibt in die laufende Session. Gegen eine
echte claude-Session verifiziert: Text und Enter muessen zwei getrennte
Writes sein, die Enter-Bytes kommen von Terminal.GenerateKeyInput.
Eine List-Handler-ConPTY-Session, die mitten in einer Phase stirbt, war bisher
ein stilles totes Tile: InteractiveTerminalViewModel.OnSessionProcessExited
setzte nur bei einem Tod innerhalb des 5s-Grace-Fensters ein sichtbares Banner.
Neu:
- HandlerRoundState (JSON unter ~/.claudeDo/handler-rounds.json, keine
EF-Migration): Phase + Task-Set der laufenden Runde, geschrieben von
BuildForMergeHelperAsync ("triage") und BuildForMergeHelperHandoffAsync.
Ohne das existieren beide nur im transienten HandoffRequested-Broadcast.
- HandlerRestartCoordinator: entscheidet den Neustart, fail-closed. Beleg fuer
"war das Usage-Limit" ist FiveHour.Utilization >= 95 zum Todeszeitpunkt --
eine ConPTY-Session hat kein stdout, TaskRunner.IsUsageLimitMessage hat hier
nichts zu lesen. Nur bei PowerUse (beide Keys), max ein Neustart pro Runde,
Warten bis FiveHour.ResetsAt, dann HandoffRequested erneut broadcasten.
Verbraucht kein Handoff-Budget (HandoffRoundTracker.Register wird nicht
angefasst). WorkerLog-Zeilen beim Tod und beim Neustart.
- Ui meldet den Exit-Code (nur dort bekannt) via
WorkerHub.ReportInteractiveSessionExited, ohne selbst zu filtern.
Sibling zu UsageLimitAutoContinueCoordinator, kein Umbau davon: der sieht nur
headless Runs.
Drei neue app_settings-Spalten (Migration AddPowerUseWindow):
power_use_window_start/end (HH:mm, lokal, nullable) und
power_use_handoff_pct (NOT NULL DEFAULT 95, geclampt 50-99).
Die Fensterlogik liegt als reine Funktion in PowerUseWindow
(Contains/Normalize/IsOpen): beide Grenzen leer oder nur eine gesetzt =
rund um die Uhr, Ende vor Start wrappt ueber Mitternacht, Grenzen
halboffen. Geprueft wird in ComposeHandlerPromptAsync, also pro Phase
zum Zeitpunkt des Prompt-Baus - die Uhr ist per Func<DateTime>
injizierbar. Bedienung im Usage-Monitor neben dem PowerUse-Toggle
(Commit auf Blur/Enter, ein Write fuer alle vier Felder); in
Settings->General unsichtbar, aber load->save durchgereicht.
Nebenbefund, gleicher Pfad: AppSettingsRepository.UpdateAsync kopiert
Feld fuer Feld und hat ModelPresets, PowerUseEnabled und
ListHandlerMaxHandoffs nie geschrieben - jeder Save dieser drei war
still wirkungslos. Mit ergaenzt und per Round-trip-Test gepinnt.
Kollision mit #264 (Handoff-Budget im Brief sichtbar machen): beide Seiten haben
HandoffRoundTracker, HandoffMcpTools und InteractiveLaunchSpecService angefasst.
Der Konflikt war inhaltlich, nicht nur textuell. #264 liest den Cap an zwei
Stellen aus der Konstante (GetStatus und das roundInfo-Rendering), waehrend
dieser Branch ihn zum Parameter aus app_settings gemacht hat. Ein reiner
Text-Merge haette kompiliert und trotzdem gelogen: Register haette den
konfigurierten Cap durchgesetzt, waehrend der Brief dem Modell weiter "2 Runden
uebrig" gemeldet haette, obwohl es 39 hat.
Aufgeloest so, dass der Cap eine einzige Quelle hat:
- GetStatus(taskId, maxHandoffs) nimmt den Cap als Parameter, mit demselben
Clamp wie Register. Die Rechnerei liegt damit an einer Stelle.
- GetHandoffStatus liest das Setting und gibt es weiter.
- Das roundInfo-Rendering benutzt GetStatus statt eigener Arithmetik.
Zwei Tests nageln genau diesen Drift fest, damit er beim naechsten Merge nicht
unbemerkt zurueckkommt: einer auf GetHandoffStatus, einer auf den Brief.
MaxHandoffs -> DefaultMaxHandoffs in #264s vier Test-Referenzen; die laufen alle
mit Default-Settings, die Semantik bleibt.
HandoffRoundTracker.Register nimmt den Cap jetzt als Parameter statt aus einer
Konstante; HandoffMcpTools liest ihn pro Aufruf aus app_settings. Werte unter 1
werden auf 1 geklemmt (in WorkerHub UND in Register): der Cap ist das Einzige,
was eine entlaufene Handoff-Kette begrenzt, "0 = unbegrenzt" darf kein Aufrufer
waehlen koennen.
InteractiveLaunchSpecService.ComposeHandlerPromptAsync haengt das PowerUse-
Addendum an den Phasen-Prompt, aber nur wenn globaler Schalter und Listen-Opt-in
beide gesetzt sind. Bewusst ein Addendum statt einer zweiten Prompt-Garnitur:
die gemeinsamen 95% bleiben an einer Stelle, und ein Run ohne PowerUse bekommt
einen byte-identischen Prompt wie vorher. Das Addendum ist als Override der
interaktiven Regeln formuliert (nie fragen, entscheiden und protokollieren;
Konflikte loesen oder abbrechen, nie im Tree liegen lassen; Arbeit ernten, wenn
eine Runde sonst leer laeuft).
Drei Tests pinnen alle vier Ecken des UND, weil die Sicherheitsaussage des
Features genau daran haengt.
app_settings bekommt power_use_enabled (Master-Schalter, wird im Usage-Monitor
gesetzt) und list_handler_max_handoffs (Default 4 = zwei Merge-Runden).
list_config bekommt power_use_allowed als Opt-in pro Liste.
Ein Handler-Run laeuft nur unbeaufsichtigt, wenn BEIDE Schalter gesetzt sind.
Damit kann der globale Schalter ueber Nacht an bleiben, ohne dass der Handler
einer nicht zugestimmten Liste sein Verhalten aendert.
UpdateListConfigDto.PowerUseAllowed ist bewusst bool? (tri-state, null =
gespeicherten Wert behalten), analog zu TicketProjectId: SetConfigAsync kopiert
verbatim, ein non-nullable bool haette jedem fremden Writer erlaubt, das Opt-in
per Auslassung zu widerrufen. Der Test dazu nagelt genau das fest.
Migration ist drei reine ALTER TABLE ADD COLUMN, kein Table-Rebuild.
Ein Task kann jetzt auf beliebig viele Vorgaenger warten. Die Einzelspalte
tasks.depends_on_task_id wird durch die Beziehungstabelle task_dependencies
ersetzt (Composite-PK, beide FKs ON DELETE CASCADE -- das reproduziert das
alte ON DELETE SET NULL: geloeschter Vorgaenger = Kante weg = Task wieder
pickbar). BlockedByTaskId bleibt unberuehrt.
- Migration AddTaskDependencies kopiert jede bestehende depends_on_task_id
verlustfrei in die Tabelle, bevor die Spalte fallengelassen wird
- QueuePicker gatet per doppelt negiertem NOT EXISTS: kein Task mit 0 Kanten
wird blockiert, alle Kanten muessen done sein, eine Kante auf einen
verschwundenen Task blockiert weiter
- SetDependsOnAsync ersetzt die ganze Menge; Zykluspruefung ist jetzt eine
BFS ueber den Graphen, faengt also auch A->B->C->A
- blockedReason nennt alle offenen Vorgaenger (External/TaskBlocking, von
ExternalMcpService und TaskWaitMcpTools gemeinsam genutzt)
- add_task/update_task nehmen dependsOnTaskIds; die alte Skalarform bleibt
als Ein-Element-Menge inkl. ""-Clear-Sentinel gueltig
- UI: Fan-in-Zeile bleibt flach und listet alle Vorgaenger im After-Label,
die lineare Kette rendert unveraendert
The handoff-budget task (round/remaining/final now surfaced in the handoff.md brief via HandoffRoundTracker.GetStatus, wired through InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync) only reaches the session at the start of a handoff round. There is no way to query the current round/remaining budget for a handler task mid-session or from outside (e.g. the UI, or a script). Consider add
ClaudeDo-Task: 0a185f8e-823d-42fc-9a5b-97c5eb91c616
A handler session only learned the chain was capped when handoff_list_handler
threw at the end of its work, stranding open tasks. The round count already
lived in HandoffRoundTracker the whole time -- it just never reached the
brief. InteractiveLaunchSpecService now reads HandoffRoundTracker.GetState
(read-only, doesn't advance the round) and renders round N of MaxHandoffs
plus the remaining count into the handoff brief, replacing the old one-line
finalNote. The rejection error also names the next action (start a new
handler run) instead of just failing.
Conflict resolution for the #266 merge: #247 had moved PreviewMerge/PreviewMergeSet
and MergePreviewSetEntryDto out of ExternalMcpService into MergeMcpTools, so #266's
in-place edits to those had to be re-applied at the new location, together with the
PreviewMergeSet_ReportsExpectsNoDiffPerTask test.
Data.Tests: default-false and true-roundtrip on TaskEntity.
Worker.Tests: add_task/update_task set/clear/leave-unchanged, and
preview_merge_set surfaces the flag per task.
`src/ClaudeDo.Worker/External/ExternalMcpService.cs` ist **1897 Zeilen** (nachgezählt 2026-09-02; die Datei ist seit dem Anlegen dieses Tasks von 1696 weiter gewachsen): ~30 DTO-Records oben plus ~25 Tool-Methoden — während **13** Geschwisterdateien `*McpTools.cs` im selben Ordner das Topic-Split-Muster schon tragen (`ListMcpTools`, `ConfigMcpTools`, `BatchMcpTools`, `NotesMcpTools`, `QueueStateMc
ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088
An empty diff from a review/audit/verification task looks identical to
one that silently failed to commit. ExpectsNoDiff (TaskEntity, add_task/
update_task, preview_merge_set, review_task via TaskRefDto) lets the
merge helper tell them apart instead of treating every 0-changed-files
result as suspicious.
`src/ClaudeDo.Worker/External/ExternalMcpService.cs` ist **1897 Zeilen** (nachgezählt 2026-09-02; die Datei ist seit dem Anlegen dieses Tasks von 1696 weiter gewachsen): ~30 DTO-Records oben plus ~25 Tool-Methoden — während **13** Geschwisterdateien `*McpTools.cs` im selben Ordner das Topic-Split-Muster schon tragen (`ListMcpTools`, `ConfigMcpTools`, `BatchMcpTools`, `NotesMcpTools`, `QueueStateMc
ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088
Guardrail for the upcoming ExternalMcpService.cs topic split: proves the
move is name-preserving by pinning the complete set of registered tool
names (computed via the SDK's own McpServerTool.Create, not a
reimplementation of the naming rule) before any file is moved.
Loc.Current (src/ClaudeDo.Ui/Localization/Loc.cs) is a process-wide mutable
static; which localizer is active depended on xUnit's parallel test
scheduling. Ui.Tests: disable collection parallelization (cheap, pure VM
suite) and install the real localizer in a module initializer so no test
can ever observe the uninitialized key-echo default. Worker.Tests: install
the same module initializer but leave parallelization on — disabling it
measured a 27% wall-clock regression on its real-SQLite/git suite, and the
initializer alone already removes the race. AgentConfigEditorViewModelTests
now installs its own localizer instead of relying on ambient state.
The tool description claimed Failed tasks carry failureReason, but the field
was missing from BatchTaskDetailDto and rejected when requested in `fields`.
Add failureReason to the DTO, KnownDetailFields, and BuildDetail so list
handlers and other callers can read failure reasons without per-task get_task
calls. Tested: 31 BatchMcpToolsTests pass, including two new cases for
failureReason retrieval on Failed and Idle tasks.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Worker.Tests carried its own hand-rolled FakeWorkerClient implementing the
whole IWorkerClient surface, duplicating StubWorkerClient in Ui.Tests. Every
hub-signature change had to be applied twice. Link StubWorkerClient.cs into
Worker.Tests via a csproj Compile Include and subclass it there; add
SetTaskStatusCalls tracking to the shared stub (the one behavior Worker.Tests
needed that Ui.Tests didn't have).
Triage phase now audits dependsOnTaskId chains before handoff: distinguishes real
from artificial (plan-numbered) dependencies, resolves artificial ones via
update_task, groups survivors into a wave plan, and checks the wave count against
the 4-handoff cap (2 merge rounds) so a chain too long to finish says so explicitly
instead of stranding mid-run.
Merge phase now reads a task's own diff when preview_merge_set's changedFileCount
is below 5, reserving the sonnet review subagent for larger diffs where the
context-protection cost is actually worth it.
Updates DefaultFor_merge_helper_merge_never_reads_diffs_itself, renamed to
DefaultFor_merge_helper_merge_gates_the_diff_subagent_on_a_file_count_threshold,
to match the new threshold behavior.