Compare commits

..
19 Commits
Author SHA1 Message Date
mika kuns ee502883f8 chore(claudedo): update CHANGELOG for v2.13.3
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 40s
2026-09-03 07:47:43 +02:00
mika kuns e64d570554 fix(worker): raise SignalR MaximumParallelInvocationsPerClient
Default of 1 serializes every hub call on a connection - a slow one
(GetTicketProjects hitting an unreachable ticket system, 10s timeout)
blocked unrelated calls like UpdateList/UpdateListConfig behind it for
the same duration, making list-settings save appear to hang.
2026-09-03 07:47:26 +02:00
ClaudeDo CI fc25ee45d1 docs(changelog): update for v2.13.2 2026-09-03 05:23:15 +00:00
mika kuns 51ee505057 fix(ui): strip CLAUDE_CODE_CHILD_SESSION/CLAUDECODE before every ConPTY launch
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 42s
ClaudeDo.App inherits its own process environment from whatever started it
(e.g. a dev/test launch from inside a claude session). Every embedded
claude.exe it spawns into a Mission Control tile then inherited those
markers too, so the child session mistook itself for a subagent: transcript
saving disabled and a degraded startup banner (white instead of orange,
missing box rules). PtyTerminalSession.StartAsync is the one choke point
all ConPTY launches already share for env handling, so clearing both vars
there covers task, ad-hoc, planning, and list-handler sessions regardless
of how ClaudeDo.App itself was started.
2026-09-03 07:22:59 +02:00
mika kuns 06bc3d2105 feat(claude-do): merge Handler-Run per Knopf abbrechen (Session killen + offene Run
ClaudeDo-Task: 3c387f8e-096f-42c6-a2c3-91c69fe3915c
2026-09-02 22:39:18 +02:00
mika kuns bd5aa265d1 feat(mission-control): Handler-Run per Knopf abbrechen
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.
2026-09-02 22:39:12 +02:00
mika kuns b298594a2c chore(claude-do): merge fix(ui): Listen-Settings-Modal verschluckt Ladefehler nach d
ClaudeDo-Task: 9256fcfe-6cad-4350-a979-0c49cd5b9799
2026-09-02 22:38:49 +02:00
mika kuns 62855ac575 Merge branch 'claudedo/c209d54cef5642278673d41e8b98fc43' 2026-09-02 22:38:42 +02:00
mika kuns 18b4dcd56f Merge branch 'claudedo/a9603b34c1e74074b904cd5028de0a1d' 2026-09-02 22:38:12 +02:00
mika kuns b02e0f80f0 chore(claude-do): merge feat(ui): Empty-State fuer den Session-Skills-Tab
ClaudeDo-Task: 5d889e35-660e-4376-a5bd-6df4e2d69e16
2026-09-02 22:36:49 +02:00
mika kuns df3e7ba0c7 fix(ui): Dequeue-X auf blockierten Queue-Zeilen anzeigen und Kette konsistent halten
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.
2026-09-02 22:34:50 +02:00
mika kuns a1b20d9f27 fix(ui): report and gate ListSettingsModal load failures instead of swallowing them
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.
2026-09-02 22:33:48 +02:00
mika kuns e0c6b670cb fix(ui): move ScrollViewer padding to inner content in modal bodies
Avalonia 12 excludes ScrollViewer.Padding from the scroll Extent, clipping
the last line of these five modal bodies unreachable at max scroll.
2026-09-02 22:30:52 +02:00
mika kuns 2bd48ea1c7 docs(open): remove stale session-skills empty-state entry
Feature was already implemented in fe8186514 (locale key, IsEmpty
property, TextBlock in SettingsModalView.axaml).
2026-09-02 22:30:37 +02:00
mika kuns 77c70d74cb perf(claude-do): merge perf: Listen-Settings-Modal öffnet erst nach 7 seriellen Rou
ClaudeDo-Task: 1a07fa19b27347c1b65591ca6c0c0980
2026-09-02 22:19:55 +02:00
mika kuns f24654dfb8 perf(ui): show list settings modal before its seven-round-trip load finishes
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.
2026-09-02 22:18:54 +02:00
mika kuns b05b6fbaec fix(ui): Mission Control oeffnen, wenn ein Handler-Run per MCP startet oder ein Handoff eintrifft 2026-09-02 22:05:34 +02:00
ClaudeDo CI eb3aee41a4 docs(changelog): update for v2.13.1 2026-09-02 20:04:37 +00:00
ClaudeDo CI 2b8733227d docs(changelog): update for v2.13.0 2026-09-02 19:58:58 +00:00
31 changed files with 866 additions and 57 deletions
+87
View File
@@ -1,5 +1,92 @@
# Changelog
## v2.13.3 — 2026-09-03
### Fixes
- raise SignalR MaximumParallelInvocationsPerClient so a slow hub call (e.g. an unreachable ticket system) can't block unrelated ones like list-settings save (b4144e6b)
## v2.13.2 — 2026-09-03
### Features
- Handler-Run per Knopf abbrechen (bd5aa265)
### Fixes
- strip CLAUDE_CODE_CHILD_SESSION/CLAUDECODE before every ConPTY launch (51ee5050)
- Dequeue-X auf blockierten Queue-Zeilen anzeigen und Kette konsistent halten (df3e7ba0)
- report and gate ListSettingsModal load failures instead of swallowing them (a1b20d9f)
- move ScrollViewer padding to inner content in modal bodies (e0c6b670)
- Mission Control oeffnen, wenn ein Handler-Run per MCP startet oder ein Handoff eintrifft (b05b6fba)
### Performance
- show list settings modal before its seven-round-trip load finishes (f24654df)
### Documentation
- remove stale session-skills empty-state entry (2bd48ea1)
- update for v2.13.1 (eb3aee41)
- update for v2.13.0 (2b873322)
## v2.13.1 — 2026-09-02
### Fixes
- Mission Control oeffnen, wenn ein Handler-Run per MCP startet oder ein Handoff eintrifft (9f64ba50)
## v2.13.0 — 2026-09-02
### Features
- Handler-Session vor dem Usage-Limit warnen und Handoff bis zum Reset zurueckhalten (fe818651)
- start_list_handler MCP-Tool startet einen List-Handler-Run wie der Button (c2166a0d)
- Handler-Session nach Usage-Limit-Reset automatisch neu starten (016b9203)
- PowerUse-Zeitfenster + Handoff-Schwellwert als Settings (69898be5)
- PowerUse-Toggle im Usage-Monitor, Opt-in pro Liste (19375254)
- PowerUse-Addendum an die Handler-Prompts, Cap aus den Settings (453d61ca)
- PowerUse-Flags und konfigurierbares Handoff-Budget (3cf9628f)
- depends_on als Menge statt Einzelfeld (c25732cf)
- surface the handoff round budget in the merge-helper handoff brief (81a5fc5d)
- add ExpectsNoDiff flag for verification/audit tasks (0b506f52)
- add dependency audit and diff-review threshold to list-handler prompts (9e805646)
- add "move to list" to the task row context menu (80fe188a)
- notes get their own list, a day log and an Enter chain (0f04b796)
- add_note MCP tool with a per-day agent cap (ae0c7d37)
### Fixes
- carry ExpectsNoDiff into the relocated merge MCP tools (4ee1d05f)
- stop Ui.Tests order-dependent flakiness on shared Loc.Current (dc1e67d0)
- add failureReason field to batch_get_tasks (91220177)
- make locale-parity test able to detect a malformed locale file (4c78a5d6)
- don't leave an orphaned list behind on cancel (56bc1651)
- stop treating git worktrees and submodules as repos (b6e5876b)
- fix Bandel ticket sync and reactivate the integration (30d0611b)
- Shift+Enter in the capture box, not just in the note rows (f6405804)
- let Shift+Enter break the line, and clear the leftovers around the editor (b90c5855)
- auto-bootstrap commit-less repos before creating worktrees (e0748ba3)
- bootstrap commit-less repos and survive missing git identity (de44196b)
### Performance
- switch to workstation GC to cut idle memory (c347e4fc)
### Refactoring
- share StubWorkerClient between Ui.Tests and Worker.Tests (9f90e75a)
- remove the stale-worker banner (6be62f02)
### Documentation
- Handler-Prompt praezisieren (Wave-Plan, Task-Split, review_task-Aufruf) (20b3b4ce)
- Test-Scoping fuer Implementer-Tasks, PowerUse in den Explore-Notes (416997d7)
- plan for multi-dependency depends_on relation table (84d955df)
- empty-repo hardening notes + verification item (cf093e31)
- spec + plan for empty-repo (unborn HEAD) hardening (fea8236e)
- update for v2.12.0 (1d6cd49b)
## v2.12.0 — 2026-08-28
### Features
+1 -3
View File
@@ -15,9 +15,7 @@ Checkliste lebt in `docs/verification-handoff.md`.
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks` (TaskRowViewModel.cs:99), `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind.
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Im `sessionSkillsTab`-Locale-Namespace (en.json:674) gibt es keinen `empty`-Key.
- **Modal-Bodies sind unten abgeschnitten** (Sichtprüfung 2026-08-06, beobachtet in den Listen-Settings: der Hinweistext unter „Verify command" ist mitten in der Zeile vom Footer weggeschnitten und lässt sich nicht herunterscrollen; laut Mika „fast überall" in den Settings-Modals). Gemeinsames Muster: `<ScrollViewer Padding="20,16">` als Modal-Body — in `ListSettingsModalView:36`, `MergeModalView:33`, `WorktreesOverviewModalView:131`, `AboutModalView:20`, `MergeHelperSelectionModal:47`, `RepoImportModalView:45`, `LogVisualizerView:45` sowie `WorkConsole:295/395`. **Vermutete** Ursache (nicht verifiziert): das untere `Padding` des ScrollViewers zählt nicht zum scrollbaren Extent, die letzten Pixel sind also unerreichbar. Erst am echten Fall nachmessen, dann ggf. einheitlich das Padding vom ScrollViewer auf ein `Margin` des inneren Inhalts umziehen.
- **Modal-Bodies unten abgeschnitten — gefixt, Sichtprüfung offen** (Ursache 2026-09-02 verifiziert: `ScrollViewer.Padding` zählt in Avalonia 12 nicht zum Extent; Fix ist das Padding auf ein `Margin` des inneren Inhalts umzuziehen). Betraf `AboutModalView`, `MergeHelperSelectionModal`, `MergeModalView`, `RepoImportModalView`, `WorktreesOverviewModalView` — alle fünf umgestellt. `ListSettingsModalView`, `LogVisualizerView` und `WorkConsole` hatten zu diesem Zeitpunkt bereits kein ScrollViewer-Padding mehr.
- **Usage-Monitor-Modal: Analyse-Tabellen ausbaufähig** (Sichtprüfung 2026-08-06; Gauges, Info-Bänder, Presets/Custom-Range und Refresh-Button sind in Ordnung). Models-Tab: die Spaltenköpfe „OTHER CACHE" und „SHARE" kollidieren zu `OTHER CACHISHARE`; `claude-haiku-4-5-20251001` läuft in die IN-Spalte; alle Zahlen linksbündig und ohne Tausendertrenner (`506830708`). Tasks-Tab: Task-Titel wird ohne Ellipse hart an der LIST-Spalte abgeschnitten, der LIST-Text ebenfalls; MODEL ist bei Runs von vor der `task_runs.model`-Spalte leer (besser „—"). Wunsch: Zahlen rechtsbündig + `#,##0` (oder k/M), Spaltenbreiten/Truncation fixen.
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien".
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal. Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen.
+4
View File
@@ -217,6 +217,10 @@ public sealed record LaunchSpec(
IReadOnlyList<string> Args,
IReadOnlyDictionary<string, string> Env);
// A handler round's still-open (Queued/Running) task, for the Mission Control "abandon run"
// action -- confirms with a count, then cancels each one before closing the pane.
public record HandlerOpenTaskDto(string Id, string Title);
public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet);
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
public record TicketProjectDto(int Id, string Title, string DepartmentName);
+5 -1
View File
@@ -354,6 +354,10 @@
"enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.",
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}",
"abandonRun": "Lauf abbrechen",
"abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen",
"abandonRunConfirm": "{0} offene(n) Task(s) abbrechen und diese Sitzung schließen? Tasks im Review oder bereits erledigt bleiben unangetastet.",
"abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}",
"question": {
"title": "Claude fragt nach",
"placeholder": "Antwort eingeben…",
@@ -772,7 +776,7 @@
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+5 -1
View File
@@ -354,6 +354,10 @@
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
"enqueueFailed": "Queue failed: {0}",
"queueRefreshFailed": "Couldn't refresh the queue: {0}",
"abandonRun": "Abandon run",
"abandonRunTip": "Cancel this run's still-open tasks and close the session",
"abandonRunConfirm": "Cancel {0} open task(s) and close this session? Tasks already in review or done are not affected.",
"abandonRunFailed": "Couldn't cancel the open tasks: {0}",
"question": {
"title": "Claude is asking",
"placeholder": "Type your answer…",
@@ -772,7 +776,7 @@
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" },
@@ -158,6 +158,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// running. The exit code only exists in this process; the worker decides whether it was a
/// list-handler session killed by the usage limit and reopens the same phase after the 5h reset.</summary>
Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default);
/// <summary>The handler round's still-open (Queued/Running) tasks, for the Mission Control
/// "abandon run" action. Empty when the round has no entry or nothing open -- the caller then
/// closes the pane without a confirmation dialog.</summary>
Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
@@ -68,6 +68,14 @@ public sealed class PtyTerminalSession : IDisposable
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
try
{
// If ClaudeDo.App itself was started from inside a claude session (e.g. a dev/test
// launch), it inherits these markers and every embedded PTY session would otherwise
// inherit them too — the child claude then thinks it's a spawned subagent and
// disables transcript saving / degrades its own startup banner. Every ConPTY tile
// must look like a fresh top-level session regardless of how ClaudeDo.App was launched.
Environment.SetEnvironmentVariable("CLAUDE_CODE_CHILD_SESSION", null);
Environment.SetEnvironmentVariable("CLAUDECODE", null);
foreach (var (key, value) in descriptor.Env)
Environment.SetEnvironmentVariable(key, value);
+3
View File
@@ -641,6 +641,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> await InvokeTimedAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", () => _hub.InvokeAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", handlerTaskId, ct));
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
@@ -236,6 +236,10 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
return names.Count == 0 ? null : names;
}
// The list_config row fetched by the most recent LoadForListAsync — exposed so a caller that
// needs the same row (ListSettingsModalViewModel) can reuse it instead of fetching it again.
public ListConfigDto? LastLoadedListConfig { get; private set; }
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
{
_suppressSave = true;
@@ -246,6 +250,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
await LoadGlobalDefaultsAsync();
var cfg = await _worker.GetListConfigAsync(listId);
LastLoadedListConfig = cfg;
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath, cfg?.PermissionMode);
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
@@ -58,7 +58,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (row is null || Dialogs is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id);
@@ -408,7 +409,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (Dialogs is not null && _services is not null)
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
await Dialogs.ShowListSettingsAsync(vm);
if (!vm.Saved && !vm.Deleted)
{
@@ -112,6 +112,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsQueued))]
[NotifyPropertyChangedFor(nameof(IsWaiting))]
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
private string? _blockedByTaskId;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
@@ -276,7 +277,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
&& !HasInteractiveSession;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks;
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
// it must be finalized first. The worker never sees a UI-hosted ConPTY session (it never
@@ -18,6 +18,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null;
/// <summary>Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge
/// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not
/// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the
/// pane is added to the visible collection, so no change notification is needed.</summary>
public bool IsListHandler { get; set; }
[ObservableProperty] private string _displayTitle;
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
@@ -26,6 +32,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
[ObservableProperty] private bool _isSubmitPending;
/// <summary>Set by the host while an abandon-run round trip (query open tasks, confirm,
/// cancel each, close pane) is in flight, so a rapid double-click can't race two runs.</summary>
[ObservableProperty] private bool _isAbandonPending;
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
public event Action<string>? ErrorReported;
@@ -36,6 +46,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Raised when the user abandons a list-handler run (<see cref="IsListHandler"/>
/// only): the host looks up this round's still-open tasks, confirms with the user (naming the
/// count), cancels them, then closes this pane -- one action instead of closing the tile and
/// separately cancelling the queue.</summary>
public event Action<string>? AbandonRunRequested;
/// <summary>Raised with <c>(taskId, exitCode)</c> when a task-based session that had been running
/// dies non-zero. The host reports it to the worker, which decides whether this was a list
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
@@ -141,6 +157,16 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
private bool CanAbandonRun() => IsListHandler && !IsAbandonPending;
[RelayCommand(CanExecute = nameof(CanAbandonRun))]
private void AbandonRun()
{
if (TaskId is { } id) AbandonRunRequested?.Invoke(id);
}
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
// re-opening the same task would otherwise just re-focus a dead tile.
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
@@ -79,6 +79,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
// View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the
// view the same way TasksIslandViewModel/DetailsIslandViewModel are.
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
@@ -375,7 +379,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => launchSpec(taskId))));
() => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true });
}
finally
{
@@ -416,7 +420,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch { /* best-effort title lookup */ }
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
{ IsListHandler = true });
}
// The 5h usage window is about to run out under this handler session: type the worker's warning
@@ -440,6 +445,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied;
pane.AbandonRunRequested += OnPaneAbandonRun;
ConPtySessions.Add(pane);
pane.Start();
}
@@ -495,6 +501,41 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
}
// Handler-run abort ("Not-Aus"): cancels every still-open (Queued/Running) task this handler
// round queued or started, then closes the pane -- one action instead of "close tile" + "cancel
// the tasks separately", which used to leave orphaned rows nobody merges. No open tasks (or no
// round entry at all -- 7-day retention, or a run predating this feature) closes the pane
// straight away, no confirmation needed. Guarded by IsAbandonPending like OnPaneSubmitForReview.
private async void OnPaneAbandonRun(string taskId)
{
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsAbandonPending)
return;
pane.IsAbandonPending = true;
try
{
var open = await _worker.GetHandlerOpenTasksAsync(taskId);
if (open.Count > 0)
{
if (ConfirmAsync is null) return;
if (!await ConfirmAsync(Loc.T("missionControl.abandonRunConfirm", open.Count))) return;
foreach (var task in open)
await _worker.CancelTaskAsync(task.Id);
}
CloseConPtySession(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.abandonRunFailed", ex.Message));
}
finally
{
pane.IsAbandonPending = false;
}
}
private void CloseConPtySession(ConPtyPaneViewModel pane)
{
if (!ConPtySessions.Contains(pane)) return;
@@ -502,6 +543,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.CloseRequested -= CloseConPtySession;
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
pane.AbandonRunRequested -= OnPaneAbandonRun;
ConPtySessions.Remove(pane);
pane.Dispose();
}
@@ -555,6 +597,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
c.CloseRequested -= CloseConPtySession;
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
c.SessionDiedAfterStartup -= OnPaneSessionDied;
c.AbandonRunRequested -= OnPaneAbandonRun;
c.Dispose();
}
ConPtySessions.Clear();
@@ -34,6 +34,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
public Func<string, Task>? ShowErrorAsync { get; set; }
// Mirrors UsageMonitorModalViewModel.ErrorReported — the caller wires this into FlashFooterError.
public event Action<string>? ErrorReported;
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = "";
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
@@ -56,6 +59,25 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
// Gates Save/Delete while the core fields (name/agent/verify command) are still loading, so a
// save can't land before they're populated and clobber the list_config row with blanks. Ticket
// projects load separately (see LoadTicketProjectsAsync) and are NOT covered by this — Save's
// own tri-state handles that case instead.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
private bool _isLoading;
// Set when the core load (name/agent/verify command) threw — keeps Save gated even after
// IsLoading resets to false in LoadAsync's finally, so a failed load can't clobber the
// stored config with blank fields.
private bool _loadFailed;
// True once GetTicketProjectsAsync has completed successfully for the current load — Save
// only writes an explicit TicketProjectId when this is true, so a slow/unreachable ticket
// system leaves the stored link untouched instead of wiping it to "none".
private bool _ticketProjectsLoaded;
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
public const string DefaultIcon = "Folder";
@@ -91,6 +113,19 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
}
// Fires the load without blocking the caller, so the modal window can be shown right away
// instead of appearing only after seven serial hub round-trips (one of them a 10s-timeout
// ticket-system HTTP call) have finished — same pattern as UsageMonitorModalViewModel.BeginLoad.
public void BeginLoad(
string listId,
string name,
string? workingDir,
string defaultCommitType,
bool isManual = false,
bool findingsTracked = false,
string? icon = null)
=> _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
public async Task LoadAsync(
string listId,
string name,
@@ -101,43 +136,81 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string? icon = null,
CancellationToken ct = default)
{
ListId = listId;
Name = name;
IsManual = isManual;
FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
? await _worker.GetTicketSettingsAsync()
: null;
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
TicketProjects.Clear();
if (TicketsAvailable)
ListConfigDto? cfg = null;
IsLoading = true;
_loadFailed = false;
try
{
ListId = listId;
Name = name;
IsManual = isManual;
FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
await Agent.LoadForListAsync(listId, ct);
cfg = Agent.LastLoadedListConfig;
VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
}
catch (Exception ex)
{
// BeginLoad fires this without awaiting it — catching here (instead of letting it
// propagate) turns what would be an unobserved task exception, with the modal stuck
// open and every field blank, into a reported error and a locked Save button.
_loadFailed = true;
ErrorReported?.Invoke(Loc.T("vm.listSettings.loadFailed", ex.Message));
}
finally { IsLoading = false; }
if (_loadFailed) return;
// Deliberately NOT awaited: an unreachable ticket system (10s HTTP timeout) must not hold
// up the rest of the modal. The dropdown just stays empty until this finishes.
_ = LoadTicketProjectsAsync(cfg?.TicketProjectId);
}
private async Task LoadTicketProjectsAsync(int? existingTicketProjectId)
{
_ticketProjectsLoaded = false;
TicketProjects.Clear();
try
{
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
? await _worker.GetTicketSettingsAsync()
: null;
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
if (!TicketsAvailable)
{
SelectedTicketProject = null;
return;
}
TicketProjects.Add(new TicketProjectOption(0, Loc.T("vm.listSettings.ticketProjectNone")));
foreach (var p in await _worker.GetTicketProjectsAsync())
TicketProjects.Add(new TicketProjectOption(p.Id, $"{p.DepartmentName} / {p.Title}"));
SelectedTicketProject = cfg?.TicketProjectId is int id
SelectedTicketProject = existingTicketProjectId is int id
? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0]
: TicketProjects[0];
_ticketProjectsLoaded = true;
}
else
catch (Exception ex)
{
SelectedTicketProject = null;
// Ticket system unreachable/slow — leave the dropdown as-is. Save's tri-state (below)
// skips writing TicketProjectId when _ticketProjectsLoaded is false, so the stored
// link survives instead of being wiped to "none".
ErrorReported?.Invoke(Loc.T("vm.listSettings.ticketProjectsLoadFailed", ex.Message));
}
}
[RelayCommand]
private bool CanSave() => !IsLoading && !_loadFailed;
private bool CanDelete() => !IsLoading;
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
await _worker.UpdateListAsync(new UpdateListDto(
@@ -149,10 +222,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
FindingsTracked,
Icon));
// Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone.
// Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it.
// Never null here, or an explicit unlink would silently fail to reach the worker.
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
// Tri-state: dropdown never shown (no base URL), or ticket projects failed/never loaded
// (e.g. an unreachable ticket system) -> null, leave the stored link alone. Dropdown shown
// and loaded -> always an explicit value, 0 for "— none —" to actively clear it. Never
// null in that case, or an explicit unlink would silently fail to reach the worker.
var ticketProjectId = TicketsAvailable && _ticketProjectsLoaded ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
// the worker as a real false rather than a "leave it alone".
@@ -165,7 +239,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
CloseAction?.Invoke();
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanDelete))]
private async Task DeleteAsync()
{
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
@@ -14,7 +14,7 @@
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1" Padding="8,3">
<Grid ColumnDefinitions="*,Auto,Auto">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding DisplayTitle}"
@@ -25,7 +25,12 @@
Content="{loc:Tr missionControl.submitForReview}"
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
Command="{Binding SubmitForReviewCommand}" />
<Button Grid.Column="2" Classes="title-ctrl"
<Button Grid.Column="2" Classes="btn" Margin="0,0,6,0"
IsVisible="{Binding IsListHandler}"
Content="{loc:Tr missionControl.abandonRun}"
ToolTip.Tip="{loc:Tr missionControl.abandonRunTip}"
Command="{Binding AbandonRunCommand}" />
<Button Grid.Column="3" Classes="title-ctrl"
Command="{Binding CloseCommand}"
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
@@ -2,6 +2,7 @@ using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
@@ -55,6 +56,7 @@ public partial class MissionControlView : UserControl
_vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
_vm.ConfirmAsync = null;
}
_vm = DataContext as MissionControlViewModel;
@@ -64,12 +66,59 @@ public partial class MissionControlView : UserControl
_vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
_vm.ConfirmAsync = ShowConfirmAsync;
}
RebuildOverviewGrid();
UpdateQueueColumn();
}
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
private async Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -17,8 +17,8 @@
<ctl:ModalShell Title="{loc:Tr modals.about.title}" CloseCommand="{Binding CloseCommand}">
<!-- Body -->
<ScrollViewer Padding="20,16" HorizontalScrollBarVisibility="Disabled">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10" ColumnSpacing="8">
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<Grid Margin="20,16" RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10" ColumnSpacing="8">
<TextBlock Classes="meta" Grid.Row="0" Grid.Column="0" Text="{loc:Tr modals.about.version}" VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Row="1" Grid.Column="0" Text="{loc:Tr modals.about.data}" VerticalAlignment="Center"/>
@@ -44,8 +44,8 @@
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
</Grid>
<ScrollViewer Padding="20,2,20,8">
<StackPanel>
<ScrollViewer>
<StackPanel Margin="20,2,20,8">
<TextBlock Classes="meta" Margin="0,8"
Text="{loc:Tr modals.mergeHelper.empty}"
IsVisible="{Binding !HasTasks}"/>
@@ -39,8 +39,8 @@
</ctl:ModalShell.Footer>
<!-- Body -->
<ScrollViewer Padding="20,16">
<StackPanel Spacing="12">
<ScrollViewer>
<StackPanel Margin="20,16" Spacing="12">
<TextBlock Classes="title" Text="{Binding TaskTitle, StringFormat='Merging: {0}'}" />
@@ -43,8 +43,8 @@
</StackPanel>
<!-- Repo checklist -->
<ScrollViewer Padding="20,2,20,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ScrollViewer>
<ItemsControl Margin="20,2,20,8" ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
@@ -134,8 +134,8 @@
</Border>
<!-- Content -->
<ScrollViewer Padding="20,16">
<StackPanel>
<ScrollViewer>
<StackPanel Margin="20,16">
<Border IsVisible="{Binding ConflictRows.Count}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource StatusErrorBrush}"
+22 -1
View File
@@ -73,6 +73,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly TicketClientFactory _ticketClients;
private readonly TicketImportService _ticketImport;
private readonly HandlerRestartCoordinator? _handlerRestart;
private readonly HandlerRoundState? _roundState;
public WorkerHub(
QueueService queue,
@@ -112,7 +113,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
TicketPatStore ticketPat = null!,
TicketClientFactory ticketClients = null!,
TicketImportService ticketImport = null!,
HandlerRestartCoordinator? handlerRestart = null)
HandlerRestartCoordinator? handlerRestart = null,
HandlerRoundState? roundState = null)
{
_queue = queue;
_waker = waker;
@@ -152,6 +154,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_ticketClients = ticketClients;
_ticketImport = ticketImport;
_handlerRestart = handlerRestart;
_roundState = roundState;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -810,6 +813,24 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_ = _handlerRestart.OnSessionExitedAsync(taskId, exitCode, CancellationToken.None);
}
// The Mission Control "abandon run" action: what a handler round queued/started that is still
// open (Queued or Running), read from the structured HandlerRoundState store rather than
// parsed out of the handler task's description. Empty when there is no round entry (7-day
// retention, or a run from before this feature) or the round has no open tasks -- the caller
// treats both as "nothing to confirm, just close the pane".
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasks(string handlerTaskId)
{
var round = _roundState?.Get(handlerTaskId);
if (round is not { TaskIds.Count: > 0 }) return Array.Empty<HandlerOpenTaskDto>();
await using var ctx = await _dbFactory.CreateDbContextAsync();
return await ctx.Tasks.AsNoTracking()
.Where(t => round.TaskIds.Contains(t.Id)
&& (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Running))
.Select(t => new HandlerOpenTaskDto(t.Id, t.Title))
.ToListAsync();
}
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
+8 -4
View File
@@ -78,10 +78,14 @@ builder.Services.AddHostedService<AttachmentOrphanRecovery>();
builder.Services.AddHostedService<PromptFileRecovery>();
builder.Services.AddHostedService<LegacyWorktreeFolderRecovery>();
builder.Services.AddHostedService<UntrackedWorktreeRecovery>();
builder.Services.AddSignalR().AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// Default MaximumParallelInvocationsPerClient=1 serializes every hub call on one connection —
// a slow one (e.g. GetTicketProjects hitting an unreachable ticket system) blocks unrelated
// calls like UpdateList/UpdateListConfig behind it for the same 10s.
builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClient = 10)
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// Runner stack.
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
@@ -430,6 +430,17 @@ public sealed class TaskStateService : ITaskStateService
if (affected == 0)
return new TransitionResult(false, "Task is not queued; cannot remove from queue.");
// A dequeued task may have been a planning-chain predecessor. Without unblocking its
// successor here, the successor is left permanently wedged on a BlockedByTaskId link to
// a task that has left the queue and will never reach the terminal state that would
// otherwise resolve it (PlanningChainCoordinator.OnChildFinishedAsync).
var successorId = await ctx.Tasks.AsNoTracking()
.Where(t => t.BlockedByTaskId == taskId)
.Select(t => t.Id)
.FirstOrDefaultAsync(ct);
if (successorId is not null)
await UnblockAsync(successorId, ct);
await NotifyAsync(taskId);
return new TransitionResult(true, null);
}
@@ -138,6 +138,8 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
=> Task.CompletedTask;
public virtual Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HandlerOpenTaskDto>>(Array.Empty<HandlerOpenTaskDto>());
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
@@ -0,0 +1,172 @@
using ClaudeDo.Data;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
// Perf fix: the modal used to await seven serial hub round-trips (including a 10s-timeout
// ticket-system HTTP call) before showing itself. LoadAsync must fetch the list config exactly
// once and must never let a slow/failing ticket-projects call block or corrupt the rest of the
// load, or the save that follows it.
public class ListSettingsModalViewModelTests
{
static ListSettingsModalViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private static IDbContextFactory<ClaudeDoDbContext> NewFactory()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_listsettings_{Guid.NewGuid():N}.db");
ClaudeDoDbContext New() => new(new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={dbPath}").Options);
using (var ctx = New()) ctx.Database.EnsureCreated();
return new TestDbFactory(New);
}
private sealed class CountingWorker : StubWorkerClient
{
public int GetListConfigCalls;
public ListConfigDto? Cfg;
public TicketSettingsDto? TicketSettings = new("http://ticket.example", true);
public Func<Task<List<TicketProjectDto>>>? TicketProjectsFactory;
public UpdateListConfigDto? SavedListConfig;
public Exception? GetListConfigThrows;
public override Task<ListConfigDto?> GetListConfigAsync(string listId)
{
GetListConfigCalls++;
if (GetListConfigThrows is not null) throw GetListConfigThrows;
return Task.FromResult(Cfg);
}
public override Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult(TicketSettings);
public override Task<List<TicketProjectDto>> GetTicketProjectsAsync() =>
TicketProjectsFactory?.Invoke() ?? Task.FromResult(new List<TicketProjectDto>());
public override Task UpdateListConfigAsync(UpdateListConfigDto dto)
{
SavedListConfig = dto;
return Task.CompletedTask;
}
}
[Fact]
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
{
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.Equal(1, worker.GetListConfigCalls);
}
[Fact]
public async Task LoadAsync_TicketProjectsNeverResolving_DoesNotBlockRestOfLoad()
{
var pending = new TaskCompletionSource<List<TicketProjectDto>>();
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, 42, null, "make test", "acceptEdits", 7, true),
TicketProjectsFactory = () => pending.Task,
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
// LoadAsync must complete on its own even though the ticket-projects call it kicked off
// never resolves -- ticket loading is fire-and-forget precisely so a slow/unreachable
// ticket system can't hold up the rest of the modal.
var loadTask = vm.LoadAsync("l1", "List One", "/repo", "feat");
var completed = await Task.WhenAny(loadTask, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(loadTask, completed);
Assert.Equal("make test", vm.VerifyCommand);
Assert.True(vm.PowerUseAllowed);
Assert.False(vm.IsLoading);
}
[Fact]
public async Task Save_WhileTicketProjectsUnreachable_PreservesStoredTicketLink()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, null, null, null, null, 7, false),
TicketProjectsFactory = () => throw new InvalidOperationException("ticket system unreachable"),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(worker.SavedListConfig);
Assert.Null(worker.SavedListConfig!.TicketProjectId);
}
[Fact]
public async Task Save_AfterSuccessfulTicketLoad_WritesSelectedTicketProject()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, null, null, null, null, 7, false),
TicketProjectsFactory = () => Task.FromResult(new List<TicketProjectDto>
{
new(7, "Fix the thing", "Dev"),
}),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal(7, worker.SavedListConfig!.TicketProjectId);
}
[Fact]
public async Task LoadAsync_CoreLoadThrows_BlocksSaveAndDoesNotOverwriteConfig()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null),
GetListConfigThrows = new InvalidOperationException("worker offline"),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.False(vm.SaveCommand.CanExecute(null));
Assert.NotNull(reported);
Assert.Contains("worker offline", reported);
Assert.Null(worker.SavedListConfig);
}
[Fact]
public async Task LoadAsync_TicketProjectsThrow_ReportsErrorButRestOfLoadStillWorks()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, null, null, "make test", null, 7, true),
TicketProjectsFactory = () => throw new InvalidOperationException("ticket system down"),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.Equal("make test", vm.VerifyCommand);
Assert.True(vm.PowerUseAllowed);
Assert.True(vm.SaveCommand.CanExecute(null));
Assert.NotNull(reported);
Assert.Contains("ticket system down", reported);
}
}
@@ -966,4 +966,110 @@ public class MissionControlViewModelTests : IDisposable
vm.ToggleLayoutCommand.Execute(null);
Assert.False(vm.IsFocusMode);
}
// ── Abandon run: single-action "stop session + cancel its still-open tasks" ────────────────
private sealed class RecordingAbandonWorker : StubWorkerClient
{
public IReadOnlyList<HandlerOpenTaskDto> OpenTasks { get; set; } = Array.Empty<HandlerOpenTaskDto>();
public readonly List<string> Cancelled = new();
public override Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult(OpenTasks);
public override Task CancelTaskAsync(string taskId)
{
Cancelled.Add(taskId);
return Task.CompletedTask;
}
}
// A plain OpenConPtySessionAsync pane (not a list handler) never shows/allows the action --
// acceptance criterion 5 ("only at handler panes").
[Fact]
public async Task AbandonRun_OnNonHandlerPane_IsNotAvailable()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
Assert.False(pane.IsListHandler);
Assert.False(pane.AbandonRunCommand.CanExecute(null));
}
[Fact]
public async Task AbandonRun_ListHandlerPane_IsListHandlerTrue_CommandAvailable()
{
var worker = new RecordingAbandonWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var pane = Assert.Single(vm.ConPtySessions);
Assert.True(pane.IsListHandler);
Assert.True(pane.AbandonRunCommand.CanExecute(null));
}
// Acceptance criterion 4: no open tasks (or no round entry, which the fake models the same
// way) closes the pane straight away -- no confirmation dialog.
[Fact]
public async Task AbandonRun_NoOpenTasks_ClosesPaneWithoutConfirming()
{
var worker = new RecordingAbandonWorker { OpenTasks = Array.Empty<HandlerOpenTaskDto>() };
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var pane = Assert.Single(vm.ConPtySessions);
var confirmCalled = false;
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.False(confirmCalled);
Assert.Empty(vm.ConPtySessions);
}
// Acceptance criterion 3 (decline branch): declining leaves the session and its tasks
// untouched.
[Fact]
public async Task AbandonRun_OpenTasks_ConfirmDeclined_LeavesSessionAndTasksUntouched()
{
var worker = new RecordingAbandonWorker
{
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
};
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
var pane = Assert.Single(vm.ConPtySessions);
string? confirmMessage = null;
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.NotNull(confirmMessage);
Assert.Empty(worker.Cancelled);
Assert.Single(vm.ConPtySessions);
}
// Acceptance criteria 1-3 (accept branch): the count is confirmed, every open task is
// cancelled, then the pane closes -- one action instead of two.
[Fact]
public async Task AbandonRun_OpenTasks_ConfirmAccepted_CancelsEachThenClosesPane()
{
var worker = new RecordingAbandonWorker
{
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
};
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
var pane = Assert.Single(vm.ConPtySessions);
vm.ConfirmAsync = _ => Task.FromResult(true);
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
Assert.Empty(vm.ConPtySessions);
}
}
@@ -41,6 +41,23 @@ public class TaskRowContextMenuTests
Assert.NotNull(row.SendToQueueDisabledReason);
}
[Fact]
public void CanRemoveFromQueue_True_When_Queued_And_Blocked()
{
var row = MakeRow();
row.Status = TaskStatus.Queued;
row.BlockedByTaskId = "predecessor-id";
Assert.True(row.CanRemoveFromQueue);
}
[Fact]
public void CanRemoveFromQueue_False_When_Idle()
{
var row = MakeRow();
row.Status = TaskStatus.Idle;
Assert.False(row.CanRemoveFromQueue);
}
[Fact]
public void CancelDisabledReason_Null_While_Running()
{
@@ -0,0 +1,141 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
/// Covers WorkerHub.GetHandlerOpenTasks -- the Mission Control "abandon run" action's read side.
/// The round's TaskIds come from HandlerRoundState (structured store, not the handler task's
/// description); only rows still Queued or Running are returned, so WaitingForReview/Done/Failed/
/// Cancelled/Idle work is never touched by a subsequent cancel loop.
public sealed class HandlerOpenTasksHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly string _roundsPath = Path.Combine(Path.GetTempPath(), $"claudedo_rounds_{Guid.NewGuid():N}.json");
private readonly HandlerRoundState _rounds;
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
{
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Array.Empty<SessionSkillEntity>());
}
public HandlerOpenTasksHubTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_rounds = new HandlerRoundState(_roundsPath);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { File.Delete(_roundsPath); } catch { /* best effort */ }
}
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var git = new GitService();
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), Microsoft.Extensions.Logging.Abstractions.NullLogger<WorktreeManager>.Instance);
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker(), _rounds);
var built = TaskStateServiceBuilder.Build(factory);
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git,
roundState: _rounds);
return hub;
}
private async Task<string> SeedListAsync(string name = "L")
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = Path.GetTempPath(), CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status, string title = "T")
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
CreatedAt = DateTime.UtcNow,
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task NoRoundEntry_ReturnsEmpty()
{
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks("no-such-handler-task");
Assert.Empty(result);
}
[Fact]
public async Task RoundWithOnlyTerminalTasks_ReturnsEmpty()
{
var listId = await SeedListAsync();
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
var review = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, "Review task");
var failed = await SeedTaskAsync(listId, TaskStatus.Failed, "Failed task");
var cancelled = await SeedTaskAsync(listId, TaskStatus.Cancelled, "Cancelled task");
var idle = await SeedTaskAsync(listId, TaskStatus.Idle, "Idle task");
var handlerTaskId = "handler-1";
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
new[] { done.Id, review.Id, failed.Id, cancelled.Id, idle.Id }, DateTime.UtcNow));
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
Assert.Empty(result);
}
[Fact]
public async Task RoundWithMixedTasks_ReturnsOnlyQueuedAndRunning()
{
var listId = await SeedListAsync();
var queued = await SeedTaskAsync(listId, TaskStatus.Queued, "Queued task");
var running = await SeedTaskAsync(listId, TaskStatus.Running, "Running task");
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
var notInRound = await SeedTaskAsync(listId, TaskStatus.Queued, "Unrelated queued task");
var handlerTaskId = "handler-2";
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
new[] { queued.Id, running.Id, done.Id }, DateTime.UtcNow));
var hub = CreateHub();
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.Id == queued.Id && r.Title == "Queued task");
Assert.Contains(result, r => r.Id == running.Id && r.Title == "Running task");
Assert.DoesNotContain(result, r => r.Id == notInRound.Id);
}
}
@@ -585,6 +585,23 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
[Fact]
public async Task DequeueToIdleAsync_MidChain_UnblocksSuccessor()
{
// pred (queued, blocked) is removed from the queue in the middle of a chain;
// successor was blocked on pred and must not be left wedged forever.
var pred = await SeedTaskAsync(TaskStatus.Queued);
var id = await SeedTaskAsync(TaskStatus.Queued, blockedBy: pred);
var successor = await SeedTaskAsync(TaskStatus.Queued, blockedBy: id);
var result = await _sut.DequeueToIdleAsync(id, default);
Assert.True(result.Ok);
var successorTask = await GetTaskAsync(successor);
Assert.Equal(TaskStatus.Queued, successorTask.Status);
Assert.Null(successorTask.BlockedByTaskId);
}
// ─── StartPlanningAsync ───────────────────────────────────────────────
[Fact]
@@ -281,6 +281,7 @@ public class TaskRowViewModelNotificationsTests
nameof(TaskRowViewModel.IsQueued),
nameof(TaskRowViewModel.IsWaiting),
nameof(TaskRowViewModel.StatusChipClass),
nameof(TaskRowViewModel.CanRemoveFromQueue),
}, raised);
}