Compare commits
38
Commits
20b3b4ce2b
...
v2.13.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f231f08a27 | ||
|
|
23491d3b49 | ||
|
|
0db9fadee9 | ||
|
|
7c7c33d677 | ||
|
|
c7392e085f | ||
|
|
c16c7b97ef | ||
|
|
cccfe2eca1 | ||
|
|
a8ec550740 | ||
|
|
40d2537486 | ||
|
|
f70503d038 | ||
|
|
9360cd2fe6 | ||
|
|
ee502883f8 | ||
|
|
e64d570554 | ||
|
|
fc25ee45d1 | ||
|
|
51ee505057 | ||
|
|
06bc3d2105 | ||
|
|
bd5aa265d1 | ||
|
|
b298594a2c | ||
|
|
62855ac575 | ||
|
|
18b4dcd56f | ||
|
|
b02e0f80f0 | ||
|
|
df3e7ba0c7 | ||
|
|
a1b20d9f27 | ||
|
|
e0c6b670cb | ||
|
|
2bd48ea1c7 | ||
|
|
77c70d74cb | ||
|
|
f24654dfb8 | ||
|
|
b05b6fbaec | ||
|
|
eb3aee41a4 | ||
|
|
2b8733227d | ||
|
|
d258ab4164 | ||
|
|
574edf6c1a | ||
|
|
fe8186514f | ||
|
|
c2166a0d4f | ||
|
|
c3a418f077 | ||
|
|
2ec3af96d5 | ||
|
|
016b9203f8 | ||
|
|
69898be5c9 |
+115
@@ -1,5 +1,120 @@
|
||||
# Changelog
|
||||
|
||||
## v2.13.5 — 2026-09-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- Version im About-Dialog aus der FileVersion lesen (cccfe2ec)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- "Nach Updates suchen" ins Hilfe-Menue verschieben (c16c7b97)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.4 (a8ec5507)
|
||||
|
||||
## v2.13.4 — 2026-09-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- interaktive Session ohne Arbeitsverzeichnis im User-Verzeichnis starten (f70503d0)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.3 (9360cd2f)
|
||||
|
||||
## v2.13.3 — 2026-09-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- raise SignalR MaximumParallelInvocationsPerClient (e64d5705)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.2 (fc25ee45)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -121,7 +121,7 @@ per call and `Register` clamps to min 1, so 0 can never mean "unbounded"). Past
|
||||
`wait`/`merge` request is silently coerced to its `_final` counterpart — check the result's
|
||||
`coerced` flag — and any handoff after a `merge_final` round throws.
|
||||
|
||||
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (toggled on the usage monitor) **and** the
|
||||
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (Settings → Execution) **and** the
|
||||
list's own `ListConfigEntity.PowerUseAllowed` are both set, `InteractiveLaunchSpecService.
|
||||
ComposeHandlerPromptAsync` appends `PromptKind.PowerUseAddendum` to whichever handler prompt the
|
||||
phase resolved to. The addendum is written as an *override* of the base prompt's interactive rules
|
||||
@@ -143,6 +143,43 @@ outgoing session is only ever told to end its turn, never to exit. This keeps th
|
||||
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
|
||||
comfortably under it (see [external-mcp.md](external-mcp.md)).
|
||||
|
||||
### Auto-restart after the usage limit
|
||||
|
||||
A handler session that dies mid-phase used to be a silent dead tile: `InteractiveTerminalViewModel.
|
||||
OnSessionProcessExited` only raised a visible banner for a death **inside** its 5 s
|
||||
`StartupGraceWindow`. It now raises `ExitedAfterStartup` for a later non-zero exit; the pane
|
||||
forwards it as `SessionDiedAfterStartup(taskId, exitCode)` and `MissionControlViewModel` reports it
|
||||
via `WorkerHub.ReportInteractiveSessionExited`. Every task-based pane reports — the UI does no
|
||||
filtering, because the exit code is the *only* thing it knows.
|
||||
|
||||
`HandlerRestartCoordinator` (Worker) decides the rest and **fails closed** — unlike the usage gate,
|
||||
every missing signal means "don't relaunch":
|
||||
|
||||
- **Which round was running** comes from `HandlerRoundState`, a JSON file at
|
||||
`~/.claudeDo/handler-rounds.json` (one `HandlerRound` per handler task id, deliberately not a DB
|
||||
column). `BuildForMergeHelperAsync` writes the opening round as phase `"triage"`;
|
||||
`BuildForMergeHelperHandoffAsync` writes each subsequent `MergeHelperPhase`. Without it the phase
|
||||
and task set exist *only* in the transient `HandoffRequested` broadcast, so a dead tile could not
|
||||
be reopened. A `"triage"` round is never reopened — there is no handoff spec for it.
|
||||
- **Was it really the usage limit?** A ConPTY session has no captured stdout, so `TaskRunner.
|
||||
IsUsageLimitMessage` (which matches the CLI's own "session limit" text) has nothing to read. The
|
||||
corroborating witness is `UsageState.Snapshot.FiveHour.Utilization ≥ 95`
|
||||
(`HandlerRestartCoordinator.UsageLimitProofPct`) at the moment of death. A crash at 40 % is not
|
||||
restarted; no snapshot at all is not restarted.
|
||||
- **PowerUse only** — both keys, exactly like the prompt addendum above.
|
||||
- **One restart per round**, claimed via `HandlerRoundState.TryClaimAutoRestart` before the wait, so
|
||||
a restart that dies again stays dead. `HandlerRoundState.Save` carries `AutoRestartUsed` over when
|
||||
the phase is unchanged, because the restart's own launch-spec build writes the round again — a
|
||||
naive overwrite would hand out a fresh budget every time and loop all night.
|
||||
- The restart waits out `FiveHour.ResetsAt`, then re-broadcasts `HandoffRequested` with the
|
||||
persisted phase + task set, which the UI already handles by reopening the tile. It goes **around**
|
||||
`handoff_list_handler`, so `HandoffRoundTracker.Register` is never called and the run loses no
|
||||
handoff budget.
|
||||
|
||||
Sibling to `UsageLimitAutoContinueCoordinator`, not an extension of it: that one only ever sees
|
||||
headless runs (`FailureReason == "usage_limit"`, resumed through the override slot), and a ConPTY
|
||||
session has no `task_runs` row to classify.
|
||||
|
||||
### The host task and its commit range
|
||||
|
||||
The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) →
|
||||
|
||||
@@ -254,6 +254,7 @@ degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds
|
||||
`usage_poll_interval_idle_seconds`.
|
||||
|
||||
The gate percentages are editable in **two** places that both write the same `app_settings` row:
|
||||
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
|
||||
Settings → Execution (typed) and the usage-monitor gauges (dragged). The throttle stages are
|
||||
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
|
||||
can't reset a dragged value.
|
||||
can't reset a dragged value. PowerUse (switch, time window, handoff %) used to be usage-monitor-only
|
||||
and is now typed on the same Settings tab, so it needs no such round-trip.
|
||||
|
||||
+1
-3
@@ -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.
|
||||
|
||||
@@ -39,12 +39,20 @@ Beyond the basics it carries:
|
||||
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
|
||||
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
|
||||
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
|
||||
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled on the **usage monitor** (not Settings). ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
|
||||
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled in **Settings → Execution**. ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
|
||||
| `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. |
|
||||
| `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. |
|
||||
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Typed in Settings → Execution; the consumer is the handoff prompt. |
|
||||
|
||||
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
|
||||
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
|
||||
⚠️ **`AppSettingsRepository.UpdateAsync` copies the incoming entity field by field** onto the
|
||||
singleton row — a property missing from that block never persists, however correctly the caller
|
||||
filled the entity, and nothing fails. `ModelPresets`, `PowerUseEnabled` and
|
||||
`ListHandlerMaxHandoffs` were all silently unwritable that way until 2026-09-02. Add a new column
|
||||
to that block, not just to the entity + the hub.
|
||||
|
||||
### Model / effort registries
|
||||
|
||||
- **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
|
||||
|
||||
@@ -72,6 +72,12 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
|
||||
builder.Property(s => s.PowerUseEnabled)
|
||||
.HasColumnName("power_use_enabled").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(s => s.PowerUseWindowStart)
|
||||
.HasColumnName("power_use_window_start");
|
||||
builder.Property(s => s.PowerUseWindowEnd)
|
||||
.HasColumnName("power_use_window_end");
|
||||
builder.Property(s => s.PowerUseHandoffPct)
|
||||
.HasColumnName("power_use_handoff_pct").IsRequired().HasDefaultValue(95);
|
||||
builder.Property(s => s.ListHandlerMaxHandoffs)
|
||||
.HasColumnName("list_handler_max_handoffs").IsRequired().HasDefaultValue(4);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPowerUseWindow : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "power_use_handoff_pct",
|
||||
table: "app_settings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 95);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "power_use_window_end",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "power_use_window_start",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "power_use_handoff_pct", "power_use_window_end", "power_use_window_start" },
|
||||
values: new object[] { 95, null, null });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "power_use_handoff_pct",
|
||||
table: "app_settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "power_use_window_end",
|
||||
table: "app_settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "power_use_window_start",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,20 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("power_use_enabled");
|
||||
|
||||
b.Property<int>("PowerUseHandoffPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(95)
|
||||
.HasColumnName("power_use_handoff_pct");
|
||||
|
||||
b.Property<string>("PowerUseWindowEnd")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("power_use_window_end");
|
||||
|
||||
b.Property<string>("PowerUseWindowStart")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("power_use_window_start");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
@@ -192,6 +206,7 @@ namespace ClaudeDo.Data.Migrations
|
||||
MaxTurnsCeiling = 80,
|
||||
NextTaskNumber = 1,
|
||||
PowerUseEnabled = false,
|
||||
PowerUseHandoffPct = 95,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
|
||||
@@ -62,13 +62,23 @@ public sealed class AppSettingsEntity
|
||||
public bool AutoContinueOnUsageLimit { get; set; }
|
||||
|
||||
// Master switch for PowerUse: list-handler runs go unattended (never ask the user) and harvest
|
||||
// new work when a round would otherwise starve. Toggled from the usage monitor, because it is a
|
||||
// deliberate "spend the remaining window" decision rather than a normal default. Off by default,
|
||||
// new work when a round would otherwise starve. Toggled in Settings → Execution. Off by default,
|
||||
// and on its own it changes nothing — a run is only in PowerUse when the LIST also opted in via
|
||||
// ListConfigEntity.PowerUseAllowed, so leaving this on cannot make an unrelated list's handler
|
||||
// run unattended. Composition lives in InteractiveLaunchSpecService.
|
||||
public bool PowerUseEnabled { get; set; }
|
||||
|
||||
// Optional local-time window (both `HH:mm`) narrowing when PowerUse applies, checked per phase
|
||||
// at prompt-build time. Both null (the default) = round the clock, byte-identical to the
|
||||
// pre-window behaviour; only one set counts as unset too. End before start wraps over midnight.
|
||||
// Semantics live in PowerUseWindow.
|
||||
public string? PowerUseWindowStart { get; set; }
|
||||
public string? PowerUseWindowEnd { get; set; }
|
||||
|
||||
// Percentage of the 5h usage window at which a PowerUse handler session is told to hand off
|
||||
// rather than start more work. Stored and displayed here; the consumer is the handoff prompt.
|
||||
public int PowerUseHandoffPct { get; set; } = 95;
|
||||
|
||||
// Server-side cap on a list-handler run's wait<->merge handoff chain (HandoffRoundTracker).
|
||||
// 4 = two full wait/merge cycles, the long-standing default. Raise it for a run meant to keep
|
||||
// going for hours. It exists to bound runaway handoff loops, so it must stay >= 1.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The optional local-time window restricting when PowerUse applies
|
||||
/// (<see cref="AppSettingsEntity.PowerUseWindowStart"/> / <c>End</c>, both <c>HH:mm</c>).
|
||||
/// Pure on purpose — the caller supplies "now", so the window logic is testable without a clock.
|
||||
/// </summary>
|
||||
public static class PowerUseWindow
|
||||
{
|
||||
/// <summary>Parses an <c>HH:mm</c> string; anything else (including null/blank) is null.</summary>
|
||||
public static TimeOnly? Parse(string? value)
|
||||
=> TimeOnly.TryParseExact(value?.Trim(), "HH:mm", out var t) ? t : null;
|
||||
|
||||
/// <summary>Round-trips a stored/typed value through <see cref="Parse"/>, so an unparseable
|
||||
/// string is stored as null instead of sitting in the column pretending to be a window.</summary>
|
||||
public static string? Normalize(string? value) => Parse(value)?.ToString("HH:mm");
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="now"/> falls inside the window. Half-open: start is included,
|
||||
/// end is not. Either bound missing = no window at all = always true (the pre-window
|
||||
/// behaviour). A window whose end is before its start wraps over midnight.
|
||||
/// </summary>
|
||||
public static bool Contains(TimeOnly now, TimeOnly? start, TimeOnly? end)
|
||||
{
|
||||
if (start is not { } from || end is not { } to) return true;
|
||||
return from < to ? now >= from && now < to : now >= from || now < to;
|
||||
}
|
||||
|
||||
/// <summary>Convenience overload for the two stored strings and a wall clock.</summary>
|
||||
public static bool IsOpen(DateTime now, string? start, string? end)
|
||||
=> Contains(TimeOnly.FromDateTime(now), Parse(start), Parse(end));
|
||||
}
|
||||
@@ -93,6 +93,16 @@ public sealed class AppSettingsRepository
|
||||
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
|
||||
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
|
||||
row.AutoContinueOnUsageLimit = updated.AutoContinueOnUsageLimit;
|
||||
// ModelPresets / PowerUse* / ListHandlerMaxHandoffs were missing from this copy block, so
|
||||
// every write of them was silently dropped (the row keeps whatever it already had). This
|
||||
// block is the single write path for the singleton row — a field absent here does not
|
||||
// persist, no matter how correctly the hub fills the entity.
|
||||
row.ModelPresets = string.IsNullOrWhiteSpace(updated.ModelPresets) ? row.ModelPresets : updated.ModelPresets;
|
||||
row.PowerUseEnabled = updated.PowerUseEnabled;
|
||||
row.PowerUseWindowStart = PowerUseWindow.Normalize(updated.PowerUseWindowStart);
|
||||
row.PowerUseWindowEnd = PowerUseWindow.Normalize(updated.PowerUseWindowEnd);
|
||||
row.PowerUseHandoffPct = Math.Clamp(updated.PowerUseHandoffPct, 50, 99);
|
||||
row.ListHandlerMaxHandoffs = Math.Max(1, updated.ListHandlerMaxHandoffs);
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -37,10 +37,15 @@ public record AppSettingsDto(
|
||||
int UsageThrottleSevenDaySoftPct = 50,
|
||||
int UsageThrottleSevenDayHardPct = 65,
|
||||
bool AutoContinueOnUsageLimit = false,
|
||||
// PowerUse master switch — toggled on the usage monitor, not typed in Settings. Only takes
|
||||
// effect for a list that also set PowerUseAllowed.
|
||||
// PowerUse master switch — Settings → Execution. Only takes effect for a list that also set
|
||||
// PowerUseAllowed.
|
||||
bool PowerUseEnabled = false,
|
||||
int ListHandlerMaxHandoffs = 4);
|
||||
int ListHandlerMaxHandoffs = 4,
|
||||
// PowerUse time window (`HH:mm`, local, null = round the clock) + the 5h-usage percentage at
|
||||
// which a PowerUse handler session should hand off. Same Settings tab.
|
||||
string? PowerUseWindowStart = null,
|
||||
string? PowerUseWindowEnd = null,
|
||||
int PowerUseHandoffPct = 95);
|
||||
|
||||
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||
|
||||
@@ -211,6 +216,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);
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
|
||||
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
|
||||
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
|
||||
"powerUseHeading": "PowerUse",
|
||||
"powerUse": "PowerUse aktivieren",
|
||||
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
|
||||
"powerUseWindow": "Nur zwischen",
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Übergabe bei (% von 5h)",
|
||||
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
|
||||
"throttleStagesHeading": "Throttle-Stufen",
|
||||
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
|
||||
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
|
||||
@@ -332,12 +339,14 @@
|
||||
"failed": "Fehlgeschlagen",
|
||||
"roadblock": "Blockade gemeldet",
|
||||
"openSession": "Sitzung öffnen",
|
||||
"homeFolder": "Home (Schnellsitzung)",
|
||||
"browseForFolder": "Ordner wählen…",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung beenden",
|
||||
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"conptyStarting": "Sitzung wird gestartet…",
|
||||
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||
@@ -353,6 +362,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…",
|
||||
@@ -402,7 +415,7 @@
|
||||
"findingsTracked": "Findings-Ordner .claudedo einchecken",
|
||||
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
|
||||
"powerUseAllowed": "PowerUse für diese Liste erlauben",
|
||||
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse im Usage-Monitor eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
|
||||
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse in Einstellungen → Ausführung eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
|
||||
"sectionIcon": "ICON",
|
||||
"icon": "Listen-Icon",
|
||||
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
|
||||
@@ -505,14 +518,18 @@
|
||||
"regenerate": "Neu erstellen",
|
||||
"emptyStateHint": "Noch kein Bericht für diesen Zeitraum. Klicke auf „Erstellen“."
|
||||
},
|
||||
"cheatSheets": {
|
||||
"title": "CHEAT SHEETS",
|
||||
"windowTitle": "Cheat Sheets",
|
||||
"pin": "Anpinnen",
|
||||
"pinTooltip": "Fenster über den anderen halten",
|
||||
"missing": "Cheat Sheet {0} nicht gefunden."
|
||||
},
|
||||
"usageMonitor": {
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "Noch keine Nutzungslimits gemeldet.",
|
||||
"powerUse": "PowerUse",
|
||||
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
|
||||
"refresh": "Jetzt aktualisieren",
|
||||
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
|
||||
"refreshHint": "Jetzt aktualisieren — Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
|
||||
"staleFormat": "Werte veraltet (Stand {0})",
|
||||
"staleGateHint": "Das Gate greift in diesem Zustand nicht.",
|
||||
"gateBlockedFormat": "Warteschlange pausiert — {0}",
|
||||
@@ -701,6 +718,9 @@
|
||||
"abort": "Merge abbrechen"
|
||||
},
|
||||
"controls": {
|
||||
"markdown": {
|
||||
"copy": "Befehl kopieren"
|
||||
},
|
||||
"datePicker": {
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
@@ -720,6 +740,13 @@
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Wochenbericht…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
"cheatSheets": "Cheat Sheets",
|
||||
"cheatSheetGit": "Git…",
|
||||
"cheatSheetCommits": "Conventional Commits…",
|
||||
"cheatSheetClaude": "Claude CLI…",
|
||||
"cheatSheetClaudeConfig": "Claude-Konfiguration…",
|
||||
"cheatSheetDotnet": "dotnet CLI…",
|
||||
"cheatSheetPowershell": "PowerShell…",
|
||||
"about": "Über…",
|
||||
"addRepos": "Repos als Listen hinzufügen…"
|
||||
},
|
||||
@@ -756,7 +783,6 @@
|
||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
|
||||
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
|
||||
"powerUseSaveFailed": "PowerUse-Schalter konnte nicht gespeichert werden: {0}",
|
||||
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
|
||||
"installFailed": "TokenTracker konnte nicht installiert werden: {0}",
|
||||
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
|
||||
@@ -767,7 +793,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}" },
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
"autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.",
|
||||
"listHandlerMaxHandoffs": "List-handler handoff budget",
|
||||
"listHandlerMaxHandoffsHint": "How many wait/merge handoffs one \"Let Claude handle it\" run may chain. 4 means two merge rounds. Raise it for runs meant to keep going for hours; it bounds a runaway chain, so it cannot be 0.",
|
||||
"powerUseHeading": "PowerUse",
|
||||
"powerUse": "Enable PowerUse",
|
||||
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
|
||||
"powerUseWindow": "Only between",
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Hand off at (% of 5h)",
|
||||
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
|
||||
"throttleStagesHeading": "Throttle stages",
|
||||
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
|
||||
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
|
||||
@@ -332,12 +339,14 @@
|
||||
"failed": "Failed",
|
||||
"roadblock": "Roadblock reported",
|
||||
"openSession": "Open session",
|
||||
"homeFolder": "Home (quick session)",
|
||||
"browseForFolder": "Choose folder…",
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"closeSession": "Kill session",
|
||||
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
||||
"conptyStarting": "Starting session…",
|
||||
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||
"mergeHelperTaskTitle": "List handler: {0}",
|
||||
@@ -353,6 +362,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…",
|
||||
@@ -402,7 +415,7 @@
|
||||
"findingsTracked": "Commit the .claudedo findings folder",
|
||||
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
|
||||
"powerUseAllowed": "Allow PowerUse for this list",
|
||||
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in the usage monitor: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
|
||||
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in Settings → Execution: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
|
||||
"sectionIcon": "ICON",
|
||||
"icon": "List icon",
|
||||
"iconHint": "Shown next to the list name in the sidebar.",
|
||||
@@ -505,14 +518,18 @@
|
||||
"regenerate": "Regenerate",
|
||||
"emptyStateHint": "No report for this range yet. Click “Generate”."
|
||||
},
|
||||
"cheatSheets": {
|
||||
"title": "CHEAT SHEETS",
|
||||
"windowTitle": "Cheat Sheets",
|
||||
"pin": "Pin",
|
||||
"pinTooltip": "Keep this window on top of the others",
|
||||
"missing": "Cheat sheet {0} not found."
|
||||
},
|
||||
"usageMonitor": {
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "No usage limits reported yet.",
|
||||
"powerUse": "PowerUse",
|
||||
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
|
||||
"refresh": "Refresh now",
|
||||
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
|
||||
"refreshHint": "Refresh now — polled every 5 min while a task runs, otherwise every 15 min.",
|
||||
"staleFormat": "Values stale (as of {0})",
|
||||
"staleGateHint": "The gate does not apply while values are stale.",
|
||||
"gateBlockedFormat": "Queue paused — {0}",
|
||||
@@ -701,6 +718,9 @@
|
||||
"abort": "Abort merge"
|
||||
},
|
||||
"controls": {
|
||||
"markdown": {
|
||||
"copy": "Copy command"
|
||||
},
|
||||
"datePicker": {
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
@@ -720,6 +740,13 @@
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Weekly Report…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
"cheatSheets": "Cheat Sheets",
|
||||
"cheatSheetGit": "Git…",
|
||||
"cheatSheetCommits": "Conventional Commits…",
|
||||
"cheatSheetClaude": "Claude CLI…",
|
||||
"cheatSheetClaudeConfig": "Claude configuration…",
|
||||
"cheatSheetDotnet": "dotnet CLI…",
|
||||
"cheatSheetPowershell": "PowerShell…",
|
||||
"about": "About…",
|
||||
"addRepos": "Add repos as lists…"
|
||||
},
|
||||
@@ -756,7 +783,6 @@
|
||||
"loadFailed": "Couldn't load usage data: {0}",
|
||||
"refreshFailed": "Couldn't refresh usage: {0}",
|
||||
"thresholdSaveFailed": "Couldn't save the threshold: {0}",
|
||||
"powerUseSaveFailed": "Couldn't save the PowerUse switch: {0}",
|
||||
"installStarted": "Installing TokenTracker — watch the log strip for progress.",
|
||||
"installFailed": "Couldn't install TokenTracker: {0}",
|
||||
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
|
||||
@@ -767,7 +793,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}" },
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Claude CLI
|
||||
|
||||
## Session starten
|
||||
- `claude` — interaktive Session im aktuellen Verzeichnis
|
||||
- `claude "<prompt>"` — Session mit erstem Prompt starten
|
||||
- `claude -c` — letzte Session in diesem Verzeichnis fortsetzen
|
||||
- `claude -r` — Session aus einer Auswahlliste fortsetzen
|
||||
- `claude -r <session-id>` — bestimmte Session fortsetzen
|
||||
- `claude --fork-session -r <id>` — Session unter neuer ID fortsetzen
|
||||
- `claude -n "<name>"` — Anzeigename für die Session setzen
|
||||
|
||||
## Modell und Aufwand
|
||||
- `claude --model sonnet` — Alias oder voller Name (`fable`, `opus`, `sonnet`, `claude-opus-5`)
|
||||
- `claude --effort high` — Denkaufwand: `low`, `medium`, `high`, `xhigh`, `max`
|
||||
- `claude --fallback-model sonnet` — Ausweichmodell bei Überlast (nur mit `-p`)
|
||||
|
||||
## Nicht interaktiv
|
||||
- `claude -p "<prompt>"` — antworten und beenden
|
||||
- `claude -p --output-format json` — ein JSON-Ergebnis
|
||||
- `claude -p --output-format stream-json` — Streaming-JSON für Skripte
|
||||
- `claude -p --include-partial-messages` — Teil-Chunks mitstreamen
|
||||
- `claude -p --max-budget-usd 5` — Kostendeckel für den Lauf
|
||||
- `claude -p --json-schema '<schema>'` — strukturierte Ausgabe validieren
|
||||
|
||||
## Berechtigungen und Tools
|
||||
- `claude --permission-mode <mode>` — `acceptEdits`, `auto`, `plan`, `manual`, `dontAsk`, `bypassPermissions`
|
||||
- `claude --allowed-tools "Bash(git *)" Edit` — nur diese Tools erlauben
|
||||
- `claude --disallowed-tools "Bash(rm *)"` — diese Tools sperren
|
||||
- `claude --tools "Bash,Edit,Read"` — Built-in-Toolset festlegen
|
||||
- `claude --add-dir <pfad>` — weiteres Arbeitsverzeichnis freigeben
|
||||
- `claude --restricted` — ohne befehlsausführende Tools und ohne lokale Settings
|
||||
|
||||
## Kontext und Konfiguration
|
||||
- `claude --append-system-prompt "<text>"` — Systemprompt ergänzen
|
||||
- `claude --system-prompt "<text>"` — Systemprompt ersetzen
|
||||
- `claude --settings <datei|json>` — zusätzliche Settings laden
|
||||
- `claude --agents '<json>'` — eigene Subagents für diese Session
|
||||
- `claude --mcp-config <datei>` — MCP-Server aus JSON laden
|
||||
- `claude --strict-mcp-config` — nur diese MCP-Server, alle anderen ignorieren
|
||||
- `claude --safe-mode` — alle Anpassungen aus, zum Fehlersuchen
|
||||
- `claude --bare` — Minimalmodus ohne Hooks, Plugins, CLAUDE.md
|
||||
|
||||
## Hintergrund-Sessions
|
||||
- `claude --bg "<prompt>"` — im Hintergrund starten, gibt die Kurz-ID aus
|
||||
- `claude agents` — laufende Hintergrund-Sessions auflisten
|
||||
- `claude attach <id>` — Hintergrund-Session in dieses Terminal holen
|
||||
- `claude logs <id>` — letzte Ausgabe einer Hintergrund-Session
|
||||
- `claude stop <id>` — Session stoppen, Verlauf bleibt
|
||||
- `claude rm <id>` — Session löschen
|
||||
|
||||
## Worktree
|
||||
- `claude -w` — neuen Git-Worktree für diese Session anlegen
|
||||
- `claude -w <name>` — Worktree mit eigenem Namen
|
||||
|
||||
## Verwaltung
|
||||
- `claude doctor` — Installation prüfen
|
||||
- `claude update` — auf neue Version aktualisieren
|
||||
- `claude mcp` — MCP-Server konfigurieren
|
||||
- `claude plugin` — Plugins verwalten
|
||||
- `claude auth` — Authentifizierung verwalten
|
||||
- `claude setup-token` — Langzeit-Token einrichten
|
||||
- `claude -d api,hooks` — Debugmodus mit Kategoriefilter
|
||||
- `claude -v` — Version ausgeben
|
||||
@@ -0,0 +1,48 @@
|
||||
# Claude Konfiguration
|
||||
|
||||
## Dateien und Orte
|
||||
- `~/.claude/settings.json` — globale Settings für alle Projekte
|
||||
- `.claude/settings.json` — Projekt-Settings, eingecheckt fürs Team
|
||||
- `.claude/settings.local.json` — persönliche Projekt-Settings, nicht eingecheckt
|
||||
- `Rangfolge: managed > local > projekt > global` — spezifischer gewinnt
|
||||
- `/config` — Settings interaktiv in der Session ansehen und ändern
|
||||
- `claude doctor` — Konfiguration auf Fehler prüfen
|
||||
|
||||
## CLAUDE.md
|
||||
- `~/.claude/CLAUDE.md` — globale Anweisungen für alle Projekte
|
||||
- `CLAUDE.md` — im Projekt-Root, wird bei Start automatisch geladen
|
||||
- `<unterordner>/CLAUDE.md` — wird geladen, sobald dort gearbeitet wird
|
||||
- `@pfad/zur/datei.md` — Import einer weiteren Datei in eine CLAUDE.md
|
||||
- `# <notiz>` — in der Session: Notiz in die CLAUDE.md übernehmen
|
||||
- `/init` — CLAUDE.md für ein Projekt generieren lassen
|
||||
|
||||
## Berechtigungen
|
||||
- `"permissions": { "allow": [...], "deny": [...], "ask": [...] }` — Struktur in settings.json
|
||||
- `Bash(git *)` — Bash-Befehle per Muster erlauben oder sperren
|
||||
- `Edit(src/**)` — Dateizugriff per Glob einschränken
|
||||
- `WebFetch(domain:example.com)` — Webzugriff auf eine Domain begrenzen
|
||||
- `mcp__<server>__<tool>` — Regel für ein einzelnes MCP-Tool
|
||||
- `/permissions` — Regeln in der Session ansehen und ergänzen
|
||||
|
||||
## MCP-Server
|
||||
- `claude mcp add <name> -- <befehl>` — lokalen stdio-Server anlegen
|
||||
- `claude mcp add --transport http <name> <url>` — HTTP-Server anlegen
|
||||
- `claude mcp add --scope project <name> ...` — in `.mcp.json` fürs Team
|
||||
- `claude mcp list` — konfigurierte Server anzeigen
|
||||
- `claude mcp remove <name>` — Server entfernen
|
||||
- `/mcp` — Serverstatus in der Session, OAuth-Login
|
||||
|
||||
## Hooks
|
||||
- `"hooks": { "PreToolUse": [...] }` — Shell-Befehle an Lifecycle-Events hängen
|
||||
- `PreToolUse / PostToolUse` — vor und nach jedem Tool-Aufruf
|
||||
- `UserPromptSubmit` — beim Absenden eines Prompts
|
||||
- `SessionStart / Stop` — bei Sessionbeginn und -ende
|
||||
- `Exit-Code 2` — blockiert die Aktion, stderr geht an Claude
|
||||
- `/hooks` — Hooks interaktiv verwalten
|
||||
|
||||
## Erweiterungen
|
||||
- `.claude/commands/<name>.md` — eigener Slash-Command `/name`
|
||||
- `.claude/skills/<name>/SKILL.md` — Skill, wird bei passender Aufgabe geladen
|
||||
- `.claude/agents/<name>.md` — eigener Subagent-Typ
|
||||
- `~/.claude/...` — dieselben Ordner global statt pro Projekt
|
||||
- `claude plugin` — Plugins installieren und verwalten
|
||||
@@ -0,0 +1,34 @@
|
||||
# Conventional Commits
|
||||
|
||||
## Format
|
||||
- `<type>(<scope>): <beschreibung>` — Grundform, Scope optional
|
||||
- `feat(auth): add login endpoint` — Beispiel mit Scope
|
||||
- `fix: prevent crash on empty input` — Beispiel ohne Scope
|
||||
- `imperativ, klein, kein Punkt am Ende` — Stil der Beschreibung
|
||||
|
||||
## Types
|
||||
- `feat` — neues Feature
|
||||
- `fix` — Bugfix
|
||||
- `refactor` — Umbau ohne Verhaltensänderung
|
||||
- `perf` — Performance-Verbesserung
|
||||
- `docs` — nur Dokumentation
|
||||
- `test` — Tests hinzufügen oder korrigieren
|
||||
- `style` — Formatierung, kein Code-Effekt
|
||||
- `build` — Build-System oder Dependencies
|
||||
- `ci` — CI-Konfiguration
|
||||
- `chore` — Sonstiges ohne src- oder Test-Änderung
|
||||
|
||||
## Breaking Changes
|
||||
- `feat(api)!: remove legacy endpoint` — `!` markiert einen Breaking Change
|
||||
- `BREAKING CHANGE: <erklärung>` — alternativ als Footer im Commit-Body
|
||||
|
||||
## Body und Footer
|
||||
- `Leerzeile nach der Betreffzeile` — dann optionaler Body mit dem Warum
|
||||
- `Refs: #123` — Footer für Ticket-Referenzen
|
||||
- `Reverts: <hash>` — Footer für Reverts
|
||||
|
||||
## Warum das Ganze
|
||||
- `feat` — löst ein Minor-Release aus (SemVer)
|
||||
- `fix` — löst ein Patch-Release aus (SemVer)
|
||||
- `!` oder `BREAKING CHANGE` — löst ein Major-Release aus
|
||||
- `git log --oneline --grep "^feat"` — Verlauf wird filterbar
|
||||
@@ -0,0 +1,49 @@
|
||||
# dotnet CLI
|
||||
|
||||
## Projekt anlegen
|
||||
- `dotnet new list` — verfügbare Vorlagen anzeigen
|
||||
- `dotnet new console -n <name>` — Projekt aus Vorlage anlegen
|
||||
- `dotnet new sln` — Solution anlegen
|
||||
- `dotnet sln add <projekt>` — Projekt in die Solution aufnehmen
|
||||
|
||||
## Bauen
|
||||
- `dotnet restore` — NuGet-Pakete auflösen
|
||||
- `dotnet build <projekt> -c Release` — Release bauen
|
||||
- `dotnet build --no-restore` — ohne Restore, wenn Pakete aktuell sind
|
||||
- `dotnet build -v minimal` — knappere Ausgabe
|
||||
- `dotnet clean -c Release` — Build-Ausgabe löschen
|
||||
|
||||
## Laufen lassen
|
||||
- `dotnet run --project <projekt> -c Release` — starten
|
||||
- `dotnet run --project <projekt> -- <args>` — Argumente an die App
|
||||
- `dotnet watch --project <projekt> run` — bei Dateiänderung neu starten
|
||||
|
||||
## Testen
|
||||
- `dotnet test <projekt> -c Release --no-build` — Tests ohne Neubau
|
||||
- `dotnet test --filter "FullyQualifiedName~<name>"` — nur passende Tests
|
||||
- `dotnet test --filter "FullyQualifiedName!~<name>"` — Tests ausschließen
|
||||
- `dotnet test --logger "console;verbosity=detailed"` — Details pro Test
|
||||
- `dotnet test --blame-hang-timeout 60s` — hängende Tests aufspüren
|
||||
|
||||
## Pakete
|
||||
- `dotnet add <projekt> package <name>` — Paket hinzufügen
|
||||
- `dotnet add <projekt> package <name> --version 1.2.3` — feste Version
|
||||
- `dotnet remove <projekt> package <name>` — Paket entfernen
|
||||
- `dotnet list <projekt> package --outdated` — veraltete Pakete
|
||||
- `dotnet nuget locals all --clear` — NuGet-Caches leeren
|
||||
|
||||
## EF Core Migrationen
|
||||
- `dotnet ef migrations add <name> -p <daten-projekt>` — Migration anlegen
|
||||
- `dotnet ef migrations list -p <daten-projekt>` — Migrationen anzeigen
|
||||
- `dotnet ef migrations remove -p <daten-projekt>` — letzte Migration zurücknehmen
|
||||
- `dotnet ef database update -p <daten-projekt>` — Schema anwenden
|
||||
|
||||
## Veröffentlichen
|
||||
- `dotnet publish <projekt> -c Release -r win-x64 --self-contained` — eigenständiges Build
|
||||
- `dotnet publish <projekt> -c Release -p:PublishSingleFile=true` — eine Exe
|
||||
|
||||
## Diagnose
|
||||
- `dotnet --info` — SDKs, Runtimes, RID
|
||||
- `dotnet --list-sdks` — installierte SDKs
|
||||
- `dotnet format <projekt>` — Formatierung anwenden
|
||||
- `dotnet build -p:TreatWarningsAsErrors=true` — Warnungen als Fehler
|
||||
@@ -0,0 +1,80 @@
|
||||
# Git
|
||||
|
||||
## Start
|
||||
- `git clone <url>` — Repository klonen
|
||||
- `git init` — neues Repository anlegen
|
||||
- `git remote add origin <url>` — Remote verbinden
|
||||
|
||||
## Stand ansehen
|
||||
- `git status -sb` — Kurzstatus mit Branch-Zeile
|
||||
- `git diff` — nicht gestagete Änderungen
|
||||
- `git diff --staged` — gestagete Änderungen
|
||||
- `git diff main...HEAD` — alles, was dieser Branch gegenüber main bringt
|
||||
- `git diff --stat` — nur Datei- und Zeilenzähler
|
||||
- `git log --oneline --graph --decorate -20` — kompakter Verlauf
|
||||
- `git log -p -- <pfad>` — Verlauf einer Datei mit Diffs
|
||||
- `git show <commit>` — ein Commit im Detail
|
||||
|
||||
## Branch
|
||||
- `git switch <branch>` — Branch wechseln
|
||||
- `git switch -c <name>` — Branch anlegen und wechseln
|
||||
- `git switch -` — zurück zum vorherigen Branch
|
||||
- `git branch -vv` — Branches mit Upstream und letztem Commit
|
||||
- `git branch -d <name>` — gemergten Branch löschen (`-D` erzwingt)
|
||||
|
||||
## Committen
|
||||
- `git add <pfad>` — Änderungen stagen
|
||||
- `git add -p` — Änderungen hunk-weise stagen
|
||||
- `git commit -m "<text>"` — gestagete Änderungen committen
|
||||
- `git commit -- <pfad>` — nur diese Pfade committen
|
||||
- `git commit --amend --no-edit` — letzten Commit um die Staging-Area erweitern
|
||||
- `git commit --fixup <commit>` — Fixup für späteres Autosquash
|
||||
|
||||
## Remote
|
||||
- `git fetch --all --prune` — alles holen, gelöschte Remote-Branches aufräumen
|
||||
- `git pull --rebase` — holen und eigene Commits obendrauf setzen
|
||||
- `git push -u origin <name>` — pushen und Upstream setzen
|
||||
- `git push --force-with-lease` — sicheres Force-Push, nie plain `--force`
|
||||
|
||||
## Rebase und Merge
|
||||
- `git rebase main` — Branch auf aktuellen main setzen
|
||||
- `git rebase -i --autosquash main` — interaktiv aufräumen
|
||||
- `git rebase --continue` — nach gelöstem Konflikt weiter
|
||||
- `git rebase --abort` — Rebase abbrechen, Ausgangszustand zurück
|
||||
- `git merge --no-ff <branch>` — Merge-Commit erzwingen
|
||||
- `git merge --abort` — laufenden Merge abbrechen
|
||||
- `git cherry-pick <commit>` — einzelnen Commit übernehmen
|
||||
|
||||
## Konflikte
|
||||
- `git diff --name-only --diff-filter=U` — konfliktbehaftete Dateien
|
||||
- `git checkout --ours <pfad>` — eigene Seite übernehmen
|
||||
- `git checkout --theirs <pfad>` — fremde Seite übernehmen
|
||||
- `git mergetool` — konfiguriertes Merge-Tool starten
|
||||
|
||||
## Rückgängig
|
||||
- `git restore <pfad>` — Datei auf HEAD zurücksetzen, Änderungen weg
|
||||
- `git restore --staged <pfad>` — unstagen, Änderungen behalten
|
||||
- `git reset --soft HEAD~1` — letzten Commit auflösen, Änderungen gestaged behalten
|
||||
- `git reset --hard <commit>` — alles verwerfen, nicht rückholbar
|
||||
- `git revert <commit>` — Commit durch Gegen-Commit aufheben
|
||||
- `git reflog` — Referenzverlauf, um verlorene Commits wiederzufinden
|
||||
|
||||
## Stash
|
||||
- `git stash push -m "<text>"` — Änderungen wegpacken
|
||||
- `git stash push -u` — inklusive unversionierter Dateien
|
||||
- `git stash list` — Stashes anzeigen
|
||||
- `git stash pop` — letzten Stash zurückholen und entfernen
|
||||
- `git stash apply <stash>` — anwenden, aber behalten
|
||||
|
||||
## Worktrees
|
||||
- `git worktree list` — alle Worktrees
|
||||
- `git worktree add <pfad> <branch>` — Worktree für bestehenden Branch
|
||||
- `git worktree add -b <name> <pfad>` — Worktree mit neuem Branch
|
||||
- `git worktree remove <pfad>` — Worktree entfernen
|
||||
- `git worktree prune` — verwaiste Einträge aufräumen
|
||||
|
||||
## Suchen
|
||||
- `git grep -n "<text>"` — im Arbeitsbaum suchen
|
||||
- `git log -S"<text>"` — Commits, die den Text einfügen oder entfernen
|
||||
- `git blame -L 40,60 <pfad>` — Zeilenbereich zuordnen
|
||||
- `git bisect start` — binäre Suche nach dem Fehler-Commit
|
||||
@@ -0,0 +1,52 @@
|
||||
# PowerShell
|
||||
|
||||
## Navigieren und Dateien
|
||||
- `Get-ChildItem` — Verzeichnis auflisten (`ls`, `-Force` zeigt versteckte)
|
||||
- `Set-Location <pfad>` — Verzeichnis wechseln (`cd`)
|
||||
- `New-Item -ItemType Directory <pfad>` — Ordner anlegen (`mkdir`)
|
||||
- `Copy-Item <quelle> <ziel> -Recurse` — kopieren
|
||||
- `Move-Item <quelle> <ziel>` — verschieben oder umbenennen
|
||||
- `Remove-Item <pfad> -Recurse -Force` — löschen, endgültig
|
||||
- `Test-Path <pfad>` — existiert die Datei oder der Ordner?
|
||||
|
||||
## Ansehen und Suchen
|
||||
- `Get-Content <datei> -Tail 50` — letzte Zeilen (`-Wait` folgt wie tail -f)
|
||||
- `Select-String "<text>" <dateien> -List` — in Dateien suchen (grep)
|
||||
- `Get-ChildItem -Recurse -Filter *.log` — Dateien rekursiv finden
|
||||
- `<befehl> | Select-Object -First 10` — Ausgabe begrenzen (head)
|
||||
- `<befehl> | Where-Object Name -like "*test*"` — Ausgabe filtern
|
||||
- `<befehl> | Measure-Object` — zählen (wc)
|
||||
- `<befehl> | Out-GridView` — Ausgabe in sortierbarem Fenster
|
||||
|
||||
## Prozesse und Dienste
|
||||
- `Get-Process | Sort-Object CPU -Descending | Select-Object -First 10` — Top-Prozesse
|
||||
- `Stop-Process -Name <name>` — Prozess beenden (`-Id <pid>` per PID)
|
||||
- `Get-Service <name>` — Dienststatus
|
||||
- `Restart-Service <name>` — Dienst neu starten (Admin nötig)
|
||||
|
||||
## Netzwerk
|
||||
- `Test-NetConnection <host> -Port 443` — Erreichbarkeit und Port testen
|
||||
- `Get-NetTCPConnection -LocalPort <port>` — wer belegt den Port (OwningProcess = PID)
|
||||
- `Invoke-RestMethod <url>` — HTTP-Aufruf, JSON kommt als Objekt zurück
|
||||
- `Resolve-DnsName <host>` — DNS-Auflösung prüfen
|
||||
|
||||
## Umgebung
|
||||
- `$env:PATH -split ";"` — PATH lesbar ausgeben
|
||||
- `$env:NAME = "wert"` — Variable für diese Session setzen
|
||||
- `[Environment]::SetEnvironmentVariable("NAME", "wert", "User")` — dauerhaft setzen
|
||||
- `Get-Command <name>` — wo liegt ein Befehl (which)
|
||||
- `Get-History` — bisherige Befehle dieser Session
|
||||
|
||||
## Windows-Spezifisches
|
||||
- `explorer .` — aktuellen Ordner im Explorer öffnen
|
||||
- `Start-Process <datei>` — mit Standardprogramm öffnen
|
||||
- `Start-Process pwsh -Verb RunAs` — Terminal als Administrator
|
||||
- `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` — lokale Skripte erlauben
|
||||
- `winget search <name>` — Software im Paketmanager suchen
|
||||
- `winget install <id>` — installieren
|
||||
- `winget upgrade --all` — alles aktualisieren
|
||||
|
||||
## Hilfe
|
||||
- `Get-Help <befehl> -Examples` — Beispiele zu einem Cmdlet
|
||||
- `Get-Member` — Eigenschaften eines Pipeline-Objekts anzeigen
|
||||
- `Get-Alias <kurzform>` — welches Cmdlet steckt hinter einem Alias
|
||||
@@ -58,13 +58,25 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
||||
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
||||
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. |
|
||||
|
||||
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
||||
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
||||
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
|
||||
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
|
||||
|
||||
`CheatSheetsViewModel` backs the modeless `CheatSheetsWindow` (Help → Cheat Sheets): content is
|
||||
markdown under `Assets/CheatSheets/*.md` as **EmbeddedResource** (not `AvaloniaResource` —
|
||||
`AssetLoader` throws without an initialized Avalonia app, which would make the VM unconstructible
|
||||
in tests), rendered through `MarkdownView`, with a `Topmost` pin so it can sit docked beside the
|
||||
shell. A new sheet = a new `.md` file + its key in `CheatSheetsViewModel.Keys` (+ a menu entry if
|
||||
it should be directly reachable). `MarkdownView` has no table/fenced-code support, so sheets are
|
||||
bullet lists with inline code — rendered via its opt-in `CommandRows` property, which turns a
|
||||
`` `command` — description `` bullet into a mono chip + copy button + description and wraps each
|
||||
`##` section in a collapsible `Expander` (blocks are added to the open section's panel, not to the
|
||||
host). The flag is opt-in because the other three `MarkdownView` call sites (prompt-file viewer,
|
||||
weekly report, task-description preview) render prose where copy buttons would be noise.
|
||||
|
||||
## Diff & Conflicts
|
||||
|
||||
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
|
||||
@@ -106,6 +118,9 @@ There is no null→bool converter — bind
|
||||
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
|
||||
sets the result on save/cancel, the caller awaits the TCS.
|
||||
|
||||
`ModalShell.HeaderActions` is the titlebar slot left of maximise/close — for a window-level action
|
||||
that should stay visible without costing a body row (currently only the usage monitor's refresh).
|
||||
|
||||
`ModalShell` also owns the window chrome for every modal: the titlebar drag goes through
|
||||
`Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only
|
||||
snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EmbeddedResource, not AvaloniaResource: reading these needs no initialized Avalonia
|
||||
app, so the VM stays constructible in tests. -->
|
||||
<EmbeddedResource Include="Assets/CheatSheets/*.md" />
|
||||
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
|
||||
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
|
||||
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
|
||||
|
||||
@@ -36,4 +36,7 @@ public interface IDialogService
|
||||
|
||||
/// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary>
|
||||
void ShowMissionControl(MissionControlViewModel vm);
|
||||
|
||||
/// <summary>Show (or re-show + focus) the modeless cheat-sheet window for the shell's shared VM.</summary>
|
||||
void ShowCheatSheets(CheatSheetsViewModel vm);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
|
||||
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
|
||||
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
|
||||
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||
/// <summary>The 5h usage window is running out under a running list-handler session:
|
||||
/// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so
|
||||
/// the session can hand off cleanly; no pane for the task means nothing happens.</summary>
|
||||
event Action<string, string>? HandlerUsageWarningEvent;
|
||||
|
||||
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
|
||||
event Action? NotesUpdatedEvent;
|
||||
|
||||
@@ -146,6 +154,14 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// session's role: "wait" | "merge" | "wait_final" | "merge_final".</summary>
|
||||
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default);
|
||||
/// <summary>Reports that a task-based ConPTY session's process exited non-zero after it had been
|
||||
/// 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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Iciclecreek.Terminal;
|
||||
@@ -67,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);
|
||||
|
||||
@@ -93,6 +102,92 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
ProcessExited?.Invoke(this, e.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types <paramref name="text"/> into the running session and submits it, exactly as if the user
|
||||
/// had typed it — used to warn a list-handler session before the usage limit
|
||||
/// (<c>HandlerUsageWarning</c>). Verified against a real `claude` TUI, including while it was
|
||||
/// mid-turn (the message is queued and acted on when the turn ends).
|
||||
///
|
||||
/// Three non-obvious details, all established by that spike:
|
||||
/// • <see cref="TerminalControl"/> exposes no public write path at all — its only public paste is
|
||||
/// <c>TerminalView.PasteAsync()</c>, which takes no argument and reads the clipboard. The
|
||||
/// private <c>SendToPtyAsync</c> on the inner view is the write seam, hence the reflection.
|
||||
/// • The text and the Enter must be TWO writes. A single <c>text + "\r"</c> chunk lands in
|
||||
/// claude's composer but is never submitted — a chunk carrying an embedded CR reads as pasted
|
||||
/// text, not as a keypress.
|
||||
/// • The Enter bytes come from the emulator (<c>GenerateKeyInput</c>) rather than a literal
|
||||
/// "\r", because claude turns Win32 input mode on and the emulator owns that encoding.
|
||||
///
|
||||
/// Best-effort by design: a package upgrade that renames the private member costs a warning that
|
||||
/// never arrives, never a crashed Mission Control.
|
||||
/// </summary>
|
||||
public async Task<bool> SendLineAsync(string text)
|
||||
{
|
||||
if (_control is not { } control || !IsRunning || string.IsNullOrEmpty(text)) return false;
|
||||
try
|
||||
{
|
||||
await SendRawAsync(control, text);
|
||||
await Task.Delay(SubmitDelay);
|
||||
await SendRawAsync(control, control.Terminal.GenerateKeyInput(XTerm.Input.Key.Enter, XTerm.Input.KeyModifiers.None));
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts <paramref name="text"/> at the cursor without submitting it — the drop-a-file-path
|
||||
/// case. Same write seam as <see cref="SendLineAsync"/>, just without the Enter.
|
||||
/// </summary>
|
||||
public async Task<bool> SendTextAsync(string text)
|
||||
{
|
||||
if (_control is not { } control || !IsRunning || string.IsNullOrEmpty(text)) return false;
|
||||
try
|
||||
{
|
||||
await SendRawAsync(control, text);
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pastes the clipboard into the session. <see cref="TerminalControl"/> wires paste to the
|
||||
/// right-click menu only, so Ctrl+V otherwise reaches the child as a raw control byte and does
|
||||
/// nothing. Goes through the emulator's own <see cref="TerminalView.PasteAsync"/> rather than
|
||||
/// writing the clipboard text raw, because that is what honours bracketed-paste mode — without
|
||||
/// it a multi-line paste is read line by line and submits on the first newline.
|
||||
/// </summary>
|
||||
public async Task PasteAsync()
|
||||
{
|
||||
if (_control is not { } control || !IsRunning) return;
|
||||
try
|
||||
{
|
||||
if (s_terminalViewField?.GetValue(control) is TerminalView view)
|
||||
await view.PasteAsync();
|
||||
}
|
||||
catch (Exception) { /* best-effort, same as SendLineAsync */ }
|
||||
}
|
||||
|
||||
// Long enough that the two writes can't be read as one chunk by the child's stdin reader.
|
||||
private static readonly TimeSpan SubmitDelay = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
private static readonly FieldInfo? s_terminalViewField =
|
||||
typeof(TerminalControl).GetField("_terminalView", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
private static readonly MethodInfo? s_sendToPty =
|
||||
typeof(TerminalView).GetMethod("SendToPtyAsync", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
private static Task SendRawAsync(TerminalControl control, string data)
|
||||
{
|
||||
if (s_terminalViewField?.GetValue(control) is not TerminalView view || s_sendToPty is null)
|
||||
throw new InvalidOperationException("Iciclecreek.Avalonia.Terminal no longer exposes a PTY write seam.");
|
||||
return (Task)s_sendToPty.Invoke(view, new object[] { data, CancellationToken.None })!;
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
try { _control?.Kill(); }
|
||||
|
||||
@@ -49,6 +49,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
@@ -159,6 +161,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
|
||||
});
|
||||
|
||||
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("HandlerUsageWarning", (taskId, message) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message));
|
||||
});
|
||||
|
||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||
@@ -626,6 +638,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct));
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -96,6 +96,13 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
|
||||
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
|
||||
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
|
||||
/// (<c>HandlerRestartCoordinator</c>). A startup death keeps using <see cref="StartError"/> instead:
|
||||
/// it never reached a state worth resuming.</summary>
|
||||
public event Action<int>? ExitedAfterStartup;
|
||||
|
||||
/// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary>
|
||||
internal void OnSessionProcessExited(object? sender, int exitCode)
|
||||
{
|
||||
@@ -103,11 +110,16 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
HasExited = true;
|
||||
ExitCode = exitCode;
|
||||
|
||||
if (exitCode == 0) return;
|
||||
if (_startedAtUtc is not { } startedAt) return; // never reached "running" — nothing ran to resume
|
||||
|
||||
// Died at startup: route through the same banner as a launch-time failure instead of
|
||||
// leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry
|
||||
// requires StartError).
|
||||
if (exitCode != 0 && _startedAtUtc is { } startedAt && _utcNow() - startedAt < StartupGraceWindow)
|
||||
if (_utcNow() - startedAt < StartupGraceWindow)
|
||||
StartError = $"Session exited immediately (code {exitCode}).";
|
||||
else
|
||||
ExitedAfterStartup?.Invoke(exitCode);
|
||||
}
|
||||
|
||||
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
|
||||
@@ -119,6 +131,19 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
StartError = message;
|
||||
}
|
||||
|
||||
/// <summary>Types a line into the live session and submits it — see
|
||||
/// <see cref="PtyTerminalSession.SendLineAsync"/>. False when there is no running process to
|
||||
/// type into (or the write seam is gone).</summary>
|
||||
public Task<bool> SendLineAsync(string text) => _session.SendLineAsync(text);
|
||||
|
||||
/// <summary>Inserts text at the cursor without submitting — see
|
||||
/// <see cref="PtyTerminalSession.SendTextAsync"/> (dropped file paths).</summary>
|
||||
public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text);
|
||||
|
||||
/// <summary>Pastes the clipboard into the session (Ctrl+V) — see
|
||||
/// <see cref="PtyTerminalSession.PasteAsync"/>.</summary>
|
||||
public Task PasteAsync() => _session.PasteAsync();
|
||||
|
||||
public void Kill() => _session.Kill();
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -329,6 +329,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
|
||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.OpenRequested = OpenMissionControl;
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
|
||||
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
|
||||
@@ -514,6 +515,19 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
|
||||
}
|
||||
|
||||
// One shared VM so the sheets are parsed once and the window keeps its selection/pin
|
||||
// across reopens; the window itself is single-instance in the dialog service.
|
||||
private CheatSheetsViewModel? _cheatSheets;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenCheatSheet(string? key)
|
||||
{
|
||||
if (Dialogs is null) return;
|
||||
_cheatSheets ??= new CheatSheetsViewModel();
|
||||
if (!string.IsNullOrEmpty(key)) _cheatSheets.Select(key);
|
||||
Dialogs.ShowCheatSheets(_cheatSheets);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenLogVisualizer()
|
||||
{
|
||||
|
||||
@@ -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,18 @@ 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
|
||||
/// no task to reopen.</summary>
|
||||
public event Action<string, int>? SessionDiedAfterStartup;
|
||||
|
||||
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
|
||||
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
|
||||
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
|
||||
@@ -49,7 +71,34 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
TaskId = taskId;
|
||||
_displayTitle = displayTitle;
|
||||
_descriptorFactory = descriptorFactory;
|
||||
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
Subscribe(Terminal);
|
||||
}
|
||||
|
||||
// Terminal is swapped wholesale on Retry, so the handlers follow the property rather than being
|
||||
// wired once at construction. (The initial value comes from the field initializer, which never
|
||||
// runs through the setter -- hence the explicit Subscribe in the constructor.)
|
||||
partial void OnTerminalChanging(InteractiveTerminalViewModel? oldValue, InteractiveTerminalViewModel newValue)
|
||||
{
|
||||
if (oldValue is not null) Unsubscribe(oldValue);
|
||||
}
|
||||
|
||||
partial void OnTerminalChanged(InteractiveTerminalViewModel value) => Subscribe(value);
|
||||
|
||||
private void Subscribe(InteractiveTerminalViewModel terminal)
|
||||
{
|
||||
terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
|
||||
}
|
||||
|
||||
private void Unsubscribe(InteractiveTerminalViewModel terminal)
|
||||
{
|
||||
terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
|
||||
}
|
||||
|
||||
private void OnTerminalExitedAfterStartup(int exitCode)
|
||||
{
|
||||
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
|
||||
}
|
||||
|
||||
private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory;
|
||||
@@ -90,6 +139,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Types a message into this pane's live session and submits it (the worker's
|
||||
/// pre-usage-limit warning). False when the session isn't running.</summary>
|
||||
public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text);
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseRequested?.Invoke(this);
|
||||
|
||||
@@ -104,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;
|
||||
@@ -111,16 +174,15 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand(CanExecute = nameof(CanRetry))]
|
||||
private void Retry()
|
||||
{
|
||||
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
Terminal.Dispose();
|
||||
Terminal = new InteractiveTerminalViewModel();
|
||||
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
var dead = Terminal;
|
||||
Terminal = new InteractiveTerminalViewModel(); // OnTerminalChanging/Changed move the handlers
|
||||
dead.Dispose();
|
||||
Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
Unsubscribe(Terminal);
|
||||
Terminal.Kill();
|
||||
Terminal.Dispose();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action _onConnectionRestored;
|
||||
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
|
||||
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
|
||||
private readonly Action<string, string> _onHandlerUsageWarning;
|
||||
|
||||
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
||||
// review/merge/status machinery.
|
||||
@@ -56,6 +58,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// View-layer seam: open the app Settings modal from the Mission Control window.
|
||||
public Action? OpenSettingsRequested { get; set; }
|
||||
|
||||
// Set by the shell: bring Mission Control on screen. A ConPTY pane only launches its process once
|
||||
// its view is attached, so a run started from outside the UI (start_list_handler MCP tool) or a
|
||||
// handoff arriving while Mission Control is closed would otherwise sit there without a process.
|
||||
public Action? OpenRequested { get; set; }
|
||||
|
||||
public bool HasPanes => Panes.Count > 0;
|
||||
|
||||
// Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows
|
||||
@@ -72,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)
|
||||
@@ -98,6 +109,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
||||
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
||||
|
||||
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
|
||||
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
|
||||
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
|
||||
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
|
||||
|
||||
_ = RefreshQueueAsync();
|
||||
_ = RefreshSessionTargetsAsync();
|
||||
}
|
||||
@@ -163,7 +179,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync()
|
||||
{
|
||||
SessionTargets.Clear();
|
||||
// Browse goes in first so the menu stays usable even if the list query below fails.
|
||||
// Home + browse go in first so the menu stays usable even if the list query below fails.
|
||||
// Home is the target for quick general questions that belong to no project.
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.homeFolder"),
|
||||
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)),
|
||||
});
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.browseForFolder"),
|
||||
@@ -363,7 +386,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => launchSpec(taskId))));
|
||||
() => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -383,6 +406,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
||||
OpenRequested?.Invoke();
|
||||
|
||||
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
|
||||
CloseConPtySession(stale);
|
||||
@@ -403,7 +427,21 @@ 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
|
||||
// into its pane so the session can hand off cleanly instead of dying mid-merge. The UI process
|
||||
// owns the terminal, so this is the only place that can reach it at all. No pane (the tile was
|
||||
// closed, or this is a second app instance) = nothing to warn, and that is not an error.
|
||||
internal async System.Threading.Tasks.Task WarnHandlerSessionAsync(string taskId, string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId) || string.IsNullOrEmpty(message)) return;
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return;
|
||||
|
||||
if (!await pane.SendLineAsync(message))
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered"));
|
||||
}
|
||||
|
||||
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
|
||||
@@ -413,10 +451,29 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||
ConPtySessions.Add(pane);
|
||||
pane.Start();
|
||||
}
|
||||
|
||||
// A ConPTY session that had been running died. The UI process owns the terminal, so this is the
|
||||
// only place the exit code exists -- report it and let the worker decide whether it was a list
|
||||
// handler killed by the usage limit and worth reopening after the reset
|
||||
// (HandlerRestartCoordinator). Every other pane is just a no-op there, so no filtering here.
|
||||
private async void OnPaneSessionDied(string taskId, int exitCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _worker.ReportInteractiveSessionExitedAsync(taskId, exitCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A worker that is down cannot restart anything anyway; the dead tile is already visible.
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
|
||||
Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
|
||||
{
|
||||
@@ -451,12 +508,49 @@ 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;
|
||||
pane.ErrorReported -= OnConPtyPaneError;
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
ConPtySessions.Remove(pane);
|
||||
pane.Dispose();
|
||||
}
|
||||
@@ -500,6 +594,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.HandoffRequestedEvent -= _onHandoffRequested;
|
||||
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
|
||||
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged -= OnPanesChanged;
|
||||
foreach (var c in ConPtySessions.ToList())
|
||||
@@ -507,6 +603,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
c.ErrorReported -= OnConPtyPaneError;
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
c.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
c.Dispose();
|
||||
}
|
||||
ConPtySessions.Clear();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using ClaudeDo.Data;
|
||||
@@ -10,8 +12,14 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class AboutModalViewModel : ViewModelBase
|
||||
{
|
||||
// MinVer sets AssemblyVersion to {Major}.0.0.0 for binding compat — Assembly.GetName().Version
|
||||
// shows "2.0.0" no matter the release. FileVersion carries the real Major.Minor.Patch.
|
||||
public string AppVersion { get; } =
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
Version.TryParse(
|
||||
FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion,
|
||||
out var version)
|
||||
? version.ToString(3)
|
||||
: "0.0.0";
|
||||
public string DataFolderPath { get; } = Paths.AppDataRoot();
|
||||
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
||||
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
/// <summary>One cheat sheet: title from the file's leading <c># </c> heading, body is the rest.</summary>
|
||||
public sealed record CheatSheet(string Key, string Title, string Body);
|
||||
|
||||
/// <summary>
|
||||
/// Backs the non-modal cheat-sheet window (Help → Cheat Sheets). Content lives as markdown
|
||||
/// under <c>Assets/CheatSheets/*.md</c> (embedded resources) and is rendered by the existing
|
||||
/// <c>MarkdownView</c>,
|
||||
/// so a new sheet is a new file — but the key list here stays explicit to keep the order
|
||||
/// deterministic and a missing resource visible instead of silently absent.
|
||||
/// </summary>
|
||||
public partial class CheatSheetsViewModel : ViewModelBase
|
||||
{
|
||||
private static readonly string[] Keys =
|
||||
["git", "conventional-commits", "claude-cli", "claude-config", "dotnet-cli", "powershell"];
|
||||
|
||||
public ObservableCollection<CheatSheet> Sheets { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private CheatSheet? _selectedSheet;
|
||||
|
||||
/// <summary>Keeps the window above the shell so it can sit docked at a screen edge.</summary>
|
||||
[ObservableProperty]
|
||||
private bool _isPinned;
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public CheatSheetsViewModel()
|
||||
{
|
||||
foreach (var key in Keys) Sheets.Add(Load(key));
|
||||
SelectedSheet = Sheets.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Preselects a sheet by key; unknown keys keep the current selection.</summary>
|
||||
public void Select(string key)
|
||||
{
|
||||
var sheet = Sheets.FirstOrDefault(s => s.Key == key);
|
||||
if (sheet is not null) SelectedSheet = sheet;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
private static CheatSheet Load(string key)
|
||||
{
|
||||
var assembly = typeof(CheatSheetsViewModel).Assembly;
|
||||
// Match by suffix rather than composing the logical name from RootNamespace — the
|
||||
// prefix is a build detail, and a rename shouldn't silently blank the sheet.
|
||||
var name = assembly.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith($".CheatSheets.{key}.md", StringComparison.Ordinal));
|
||||
var stream = name is null ? null : assembly.GetManifestResourceStream(name);
|
||||
if (stream is null)
|
||||
return new CheatSheet(key, key, Loc.T("modals.cheatSheets.missing", $"{key}.md"));
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var text = reader.ReadToEnd().Replace("\r\n", "\n");
|
||||
|
||||
// The window shows the title in its own selector, so strip the leading heading
|
||||
// rather than rendering it a second time inside the body.
|
||||
var lines = new List<string>(text.Split('\n'));
|
||||
var title = key;
|
||||
var first = lines.FindIndex(l => !string.IsNullOrWhiteSpace(l));
|
||||
if (first >= 0 && lines[first].StartsWith("# ", StringComparison.Ordinal))
|
||||
{
|
||||
title = lines[first][2..].Trim();
|
||||
lines.RemoveRange(0, first + 1);
|
||||
}
|
||||
return new CheatSheet(key, title, string.Join('\n', lines).Trim());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -46,8 +49,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
|
||||
[ObservableProperty] private string _verifyCommand = "";
|
||||
// Per-list opt-in for PowerUse. A handler run on this list only goes unattended (no questions,
|
||||
// harvests new work, longer handoff chain) when this AND the global switch in the usage monitor
|
||||
// are both on. Off by default — the global switch alone must never change a list's behaviour.
|
||||
// harvests new work, longer handoff chain) when this AND the global switch in Settings →
|
||||
// Execution are both on. Off by default — the global switch alone must never change a list's behaviour.
|
||||
[ObservableProperty] private bool _powerUseAllowed;
|
||||
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
|
||||
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
|
||||
@@ -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,
|
||||
@@ -100,6 +135,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
bool findingsTracked = false,
|
||||
string? icon = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ListConfigDto? cfg = null;
|
||||
IsLoading = true;
|
||||
_loadFailed = false;
|
||||
try
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
@@ -112,32 +152,65 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
await Agent.LoadForListAsync(listId, ct);
|
||||
var cfg = await _worker.GetListConfigAsync(listId);
|
||||
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);
|
||||
TicketProjects.Clear();
|
||||
if (TicketsAvailable)
|
||||
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;
|
||||
|
||||
@@ -30,6 +30,15 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
// Cap on a list-handler run's wait<->merge handoff chain. 4 = two merge rounds (the long-standing
|
||||
// default); raise it for runs meant to keep going for hours. Bounds a runaway chain, so never 0.
|
||||
[ObservableProperty] private int _listHandlerMaxHandoffs = 4;
|
||||
// PowerUse master switch: a handler run spends the rest of the window unattended. On its own it
|
||||
// changes nothing — the run's LIST must also opt in (list settings → PowerUse).
|
||||
[ObservableProperty] private bool _powerUseEnabled;
|
||||
// Optional local-time window (HH:mm) narrowing when PowerUse applies; both blank = round the
|
||||
// clock. Unparseable input is normalized to null on save, same as the worker does.
|
||||
[ObservableProperty] private string? _powerUseWindowStart;
|
||||
[ObservableProperty] private string? _powerUseWindowEnd;
|
||||
// 5h-usage percentage at which a PowerUse handler session hands off. Clamped 50..99.
|
||||
[ObservableProperty] private int _powerUseHandoffPct = 95;
|
||||
// Newline-separated path prefixes excluded from the weekly report.
|
||||
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
|
||||
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
@@ -130,10 +131,6 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
// carried through load→save verbatim so saving Settings can never reset a dragged value.
|
||||
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
|
||||
|
||||
// Same deal: the PowerUse master switch is toggled on the usage monitor. Carried load→save so
|
||||
// opening and saving Settings can't switch it off behind the user's back.
|
||||
private bool _powerUseEnabled;
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
@@ -152,10 +149,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
|
||||
General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit;
|
||||
General.ListHandlerMaxHandoffs = dto.ListHandlerMaxHandoffs;
|
||||
// Owned by the usage monitor, not editable here — but it MUST be round-tripped,
|
||||
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
|
||||
// the record default (false) back and silently switch PowerUse off.
|
||||
_powerUseEnabled = dto.PowerUseEnabled;
|
||||
General.PowerUseEnabled = dto.PowerUseEnabled;
|
||||
General.PowerUseWindowStart = dto.PowerUseWindowStart;
|
||||
General.PowerUseWindowEnd = dto.PowerUseWindowEnd;
|
||||
General.PowerUseHandoffPct = dto.PowerUseHandoffPct;
|
||||
_throttleStages = (
|
||||
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
|
||||
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
|
||||
@@ -223,8 +220,13 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
_throttleStages.SevenSoft,
|
||||
_throttleStages.SevenHard,
|
||||
General.AutoContinueOnUsageLimit,
|
||||
_powerUseEnabled,
|
||||
General.ListHandlerMaxHandoffs);
|
||||
General.PowerUseEnabled,
|
||||
General.ListHandlerMaxHandoffs,
|
||||
// The worker stores an unparseable time as null; normalize here too so the box
|
||||
// shows what was actually saved instead of a typo that looks like a live window.
|
||||
PowerUseWindow.Normalize(General.PowerUseWindowStart),
|
||||
PowerUseWindow.Normalize(General.PowerUseWindowEnd),
|
||||
Math.Clamp(General.PowerUseHandoffPct, 50, 99));
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -100,45 +100,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
|
||||
|
||||
/// <summary>
|
||||
/// PowerUse master switch. It lives here rather than in Settings because turning it on is a
|
||||
/// "spend the rest of the window" decision, made while looking at how much window is left.
|
||||
/// On its own it changes nothing — a list-handler run only goes unattended when its LIST also
|
||||
/// opted in (list settings → PowerUse), so this can stay on without affecting other lists.
|
||||
/// </summary>
|
||||
[ObservableProperty] private bool _powerUseEnabled;
|
||||
|
||||
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
|
||||
// write it straight back to the worker.
|
||||
private bool _loadingPowerUse;
|
||||
|
||||
partial void OnPowerUseEnabledChanged(bool value)
|
||||
{
|
||||
if (_loadingPowerUse) return;
|
||||
_ = SavePowerUseAsync(value);
|
||||
}
|
||||
|
||||
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
|
||||
/// doesn't own.
|
||||
private async Task SavePowerUseAsync(bool value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = await _worker.GetAppSettingsAsync();
|
||||
if (settings is null)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
|
||||
return;
|
||||
}
|
||||
|
||||
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsStale => Snapshot?.IsStale == true;
|
||||
public string? LastError => Snapshot?.LastError;
|
||||
public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
|
||||
@@ -188,14 +149,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
||||
|
||||
var settings = await _worker.GetAppSettingsAsync();
|
||||
if (settings is not null)
|
||||
{
|
||||
_loadingPowerUse = true;
|
||||
try { PowerUseEnabled = settings.PowerUseEnabled; }
|
||||
finally { _loadingPowerUse = false; }
|
||||
}
|
||||
|
||||
ApplyPresetRange(SelectedPresetDays);
|
||||
await LoadUsageDataAsync();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Controls;
|
||||
|
||||
@@ -13,12 +17,27 @@ public partial class MarkdownView : UserControl
|
||||
public static readonly StyledProperty<string?> MarkdownProperty =
|
||||
AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown));
|
||||
|
||||
/// <summary>
|
||||
/// Renders a bullet that starts with inline code as a command row: the command as a
|
||||
/// monospace chip with a copy button, the rest as a description below it, and a rule under
|
||||
/// each <c>##</c> section. Opt-in — the prompt-file viewer and the weekly report render
|
||||
/// prose where nearly every bullet carries inline code, and copy buttons there are noise.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<bool> CommandRowsProperty =
|
||||
AvaloniaProperty.Register<MarkdownView, bool>(nameof(CommandRows));
|
||||
|
||||
public string? Markdown
|
||||
{
|
||||
get => GetValue(MarkdownProperty);
|
||||
set => SetValue(MarkdownProperty, value);
|
||||
}
|
||||
|
||||
public bool CommandRows
|
||||
{
|
||||
get => GetValue(CommandRowsProperty);
|
||||
set => SetValue(CommandRowsProperty, value);
|
||||
}
|
||||
|
||||
private StackPanel? _host;
|
||||
|
||||
public MarkdownView()
|
||||
@@ -34,6 +53,8 @@ public partial class MarkdownView : UserControl
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == MarkdownProperty)
|
||||
Render(change.GetNewValue<string?>());
|
||||
else if (change.Property == CommandRowsProperty)
|
||||
Render(Markdown);
|
||||
}
|
||||
|
||||
private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled);
|
||||
@@ -48,6 +69,9 @@ public partial class MarkdownView : UserControl
|
||||
return;
|
||||
|
||||
var lines = md.Replace("\r\n", "\n").Split('\n');
|
||||
// In CommandRows mode every "## " opens a collapsible section and blocks land inside it;
|
||||
// otherwise this stays the host for the whole document.
|
||||
var target = _host;
|
||||
int i = 0;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
@@ -56,19 +80,22 @@ public partial class MarkdownView : UserControl
|
||||
|
||||
if (line.StartsWith("### "))
|
||||
{
|
||||
_host.Children.Add(Heading(line[4..], 13));
|
||||
target.Children.Add(Heading(line[4..], 13));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("## "))
|
||||
{
|
||||
_host.Children.Add(Heading(line[3..], 15));
|
||||
if (CommandRows)
|
||||
target = OpenSection(line[3..]);
|
||||
else
|
||||
target.Children.Add(Heading(line[3..], 15));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("# "))
|
||||
{
|
||||
_host.Children.Add(Heading(line[2..], 17));
|
||||
target.Children.Add(Heading(line[2..], 17));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -77,7 +104,10 @@ public partial class MarkdownView : UserControl
|
||||
{
|
||||
while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* ")))
|
||||
{
|
||||
_host.Children.Add(Bullet(lines[i][2..]));
|
||||
var item = lines[i][2..];
|
||||
target.Children.Add(CommandRows && TrySplitCommand(item, out var cmd, out var desc)
|
||||
? CommandRow(cmd, desc)
|
||||
: Bullet(item));
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
@@ -89,7 +119,7 @@ public partial class MarkdownView : UserControl
|
||||
while (i < lines.Length && NumberedItem.IsMatch(lines[i]))
|
||||
{
|
||||
var m = NumberedItem.Match(lines[i]);
|
||||
_host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
|
||||
target.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
@@ -104,7 +134,7 @@ public partial class MarkdownView : UserControl
|
||||
paragraph.Append(lines[i].Trim());
|
||||
i++;
|
||||
}
|
||||
_host.Children.Add(Paragraph(paragraph.ToString()));
|
||||
target.Children.Add(Paragraph(paragraph.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +156,120 @@ public partial class MarkdownView : UserControl
|
||||
return tb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a collapsible section for a <c>##</c> heading and returns the panel its blocks go
|
||||
/// into. Native <see cref="Expander"/> — it carries the chevron, keyboard toggle and
|
||||
/// animation, and the app already uses it unstyled in the worktrees overview.
|
||||
/// </summary>
|
||||
private StackPanel OpenSection(string title)
|
||||
{
|
||||
var header = new TextBlock
|
||||
{
|
||||
FontSize = 15,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
};
|
||||
AppendInlines(header.Inlines!, title);
|
||||
|
||||
var body = new StackPanel { Spacing = 6 };
|
||||
_host!.Children.Add(new Expander
|
||||
{
|
||||
Header = header,
|
||||
Content = body,
|
||||
IsExpanded = true,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
});
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits <c>`command` — description</c> into its two parts. False when the bullet doesn't
|
||||
/// open with inline code, so prose bullets keep the plain renderer.
|
||||
/// </summary>
|
||||
internal static bool TrySplitCommand(string text, out string command, out string description)
|
||||
{
|
||||
command = "";
|
||||
description = "";
|
||||
if (text.Length < 3 || text[0] != '`') return false;
|
||||
var close = text.IndexOf('`', 1);
|
||||
if (close <= 1) return false;
|
||||
command = text[1..close];
|
||||
description = text[(close + 1)..].TrimStart(' ', '—', '–', '-', ':').Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
private Control CommandRow(string command, string description)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
};
|
||||
|
||||
var text = new SelectableTextBlock { Text = command, TextWrapping = TextWrapping.Wrap };
|
||||
// Bound, not resolved: Render can run while detached, where a resource lookup finds
|
||||
// nothing and would freeze on the fallback.
|
||||
text.Bind(TextBlock.FontFamilyProperty, this.GetResourceObservable("MonoFont"));
|
||||
text.Bind(TextBlock.FontSizeProperty, this.GetResourceObservable("FontSizeMono"));
|
||||
|
||||
var chip = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(7, 4),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Child = text,
|
||||
};
|
||||
chip.Bind(Border.BackgroundProperty, this.GetResourceObservable("Surface2Brush"));
|
||||
|
||||
var stack = new StackPanel { Spacing = 3, Children = { chip } };
|
||||
if (description.Length > 0)
|
||||
{
|
||||
var desc = new TextBlock
|
||||
{
|
||||
Classes = { "body" },
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(2, 0, 0, 0),
|
||||
};
|
||||
AppendInlines(desc.Inlines!, description);
|
||||
stack.Children.Add(desc);
|
||||
}
|
||||
Grid.SetColumn(stack, 0);
|
||||
grid.Children.Add(stack);
|
||||
|
||||
var copy = CopyButton(command);
|
||||
Grid.SetColumn(copy, 1);
|
||||
grid.Children.Add(copy);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private Button CopyButton(string command)
|
||||
{
|
||||
var copyIcon = new PathIcon { Width = 11, Height = 11 };
|
||||
copyIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Copy"));
|
||||
var checkIcon = new PathIcon { Width = 11, Height = 11 };
|
||||
checkIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Check"));
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "icon-btn" },
|
||||
Content = copyIcon,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
};
|
||||
ToolTip.SetTip(button, Loc.T("controls.markdown.copy"));
|
||||
|
||||
button.Click += async (_, _) =>
|
||||
{
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return;
|
||||
await clipboard.SetTextAsync(command);
|
||||
// The clipboard gives no feedback of its own; a brief check mark is the confirmation.
|
||||
button.Content = checkIcon;
|
||||
DispatcherTimer.RunOnce(() => button.Content = copyIcon, TimeSpan.FromSeconds(1.5));
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
private static Control Paragraph(string text)
|
||||
{
|
||||
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
||||
|
||||
@@ -14,19 +14,21 @@
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="14,0">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto" Margin="14,0">
|
||||
<TextBlock Text="{TemplateBinding Title}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{DynamicResource FontSizeMono}"
|
||||
LetterSpacing="1.4"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Name="PART_MaxButton" Grid.Column="1" Classes="icon-btn"
|
||||
<ContentPresenter Grid.Column="1" Content="{TemplateBinding HeaderActions}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<Button Name="PART_MaxButton" Grid.Column="2" Classes="icon-btn"
|
||||
IsVisible="{TemplateBinding ShowMaximizeButton}"
|
||||
VerticalAlignment="Center">
|
||||
<PathIcon Name="PART_MaxIcon" Data="{StaticResource Icon.WinMax}" Width="10" Height="10"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2" Classes="icon-btn" Content="✕"
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="✕"
|
||||
FontSize="{DynamicResource FontSizeBody}"
|
||||
Command="{TemplateBinding CloseCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
@@ -17,6 +17,11 @@ public class ModalShell : ContentControl
|
||||
public static readonly StyledProperty<object?> FooterProperty =
|
||||
AvaloniaProperty.Register<ModalShell, object?>(nameof(Footer));
|
||||
|
||||
// Window-level actions rendered in the titlebar, left of maximise/close. Keeps a single
|
||||
// always-visible action (e.g. the usage monitor's refresh) out of the body's vertical flow.
|
||||
public static readonly StyledProperty<object?> HeaderActionsProperty =
|
||||
AvaloniaProperty.Register<ModalShell, object?>(nameof(HeaderActions));
|
||||
|
||||
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
|
||||
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
|
||||
|
||||
@@ -27,6 +32,7 @@ public class ModalShell : ContentControl
|
||||
|
||||
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
|
||||
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
|
||||
public object? HeaderActions { get => GetValue(HeaderActionsProperty); set => SetValue(HeaderActionsProperty, value); }
|
||||
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
|
||||
public bool ShowMaximizeButton { get => GetValue(ShowMaximizeButtonProperty); set => SetValue(ShowMaximizeButtonProperty, value); }
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
xmlns:term="using:Iciclecreek.Terminal"
|
||||
x:Class="ClaudeDo.Ui.Views.InteractiveTerminalView"
|
||||
x:DataType="vm:InteractiveTerminalViewModel"
|
||||
x:Name="Root">
|
||||
x:Name="Root"
|
||||
DragDrop.AllowDrop="True">
|
||||
<!--
|
||||
Process="" suppresses TerminalView.OnLoaded's built-in auto-launch (it otherwise spawns its
|
||||
own cmd.exe/bash the moment this control loads). PtyTerminalSession sets Process/Args/
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
@@ -12,6 +16,12 @@ public partial class InteractiveTerminalView : UserControl
|
||||
InitializeComponent();
|
||||
TerminalHost.Loaded += OnTerminalHostLoaded;
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
|
||||
// Tunnel: TerminalControl handles KeyDown itself and forwards Ctrl+V to the child as a raw
|
||||
// control byte, where nothing acts on it — paste has to be intercepted before it gets there.
|
||||
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
@@ -25,4 +35,44 @@ public partial class InteractiveTerminalView : UserControl
|
||||
if (DataContext is InteractiveTerminalViewModel vm)
|
||||
vm.AttachControl(TerminalHost);
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.V || !e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Alt))
|
||||
return;
|
||||
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
||||
|
||||
e.Handled = true;
|
||||
_ = vm.PasteAsync();
|
||||
}
|
||||
|
||||
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
e.DragEffects = IsFilesDrop(e) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// Dropping files types their paths into the session's composer (no Enter) — the common case is
|
||||
// "look at this file", not "run something".
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (DataContext is not InteractiveTerminalViewModel vm || !IsFilesDrop(e)) return;
|
||||
e.Handled = true;
|
||||
|
||||
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath()));
|
||||
if (text.Length > 0) _ = vm.SendTextAsync(text);
|
||||
}
|
||||
|
||||
/// <summary>Joins dropped file paths into one insertable chunk: quoted when they contain a
|
||||
/// space, space-separated, with a trailing space so the user can keep typing.</summary>
|
||||
internal static string FormatDroppedPaths(IEnumerable<string?>? paths)
|
||||
{
|
||||
var usable = (paths ?? Enumerable.Empty<string?>())
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p!.Contains(' ') ? $"\"{p}\"" : p)
|
||||
.ToList();
|
||||
return usable.Count == 0 ? string.Empty : string.Join(" ", usable) + " ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
Foreground="{DynamicResource TextDimBrush}">
|
||||
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.usageMonitor}"
|
||||
Command="{Binding OpenUsageMonitorCommand}"/>
|
||||
</MenuItem>
|
||||
@@ -79,7 +77,23 @@
|
||||
<MenuItem Header="{loc:Tr shell.menu.help}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource TextDimBrush}">
|
||||
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.cheatSheets}">
|
||||
<MenuItem Header="{loc:Tr shell.menu.cheatSheetGit}"
|
||||
Command="{Binding OpenCheatSheetCommand}" CommandParameter="git"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.cheatSheetCommits}"
|
||||
Command="{Binding OpenCheatSheetCommand}" CommandParameter="conventional-commits"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.cheatSheetClaude}"
|
||||
Command="{Binding OpenCheatSheetCommand}" CommandParameter="claude-cli"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.cheatSheetClaudeConfig}"
|
||||
Command="{Binding OpenCheatSheetCommand}" CommandParameter="claude-config"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.cheatSheetDotnet}"
|
||||
Command="{Binding OpenCheatSheetCommand}" CommandParameter="dotnet-cli"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.cheatSheetPowershell}"
|
||||
Command="{Binding OpenCheatSheetCommand}" CommandParameter="powershell"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.CheatSheetsWindow"
|
||||
x:DataType="vm:CheatSheetsViewModel"
|
||||
Title="{loc:Tr modals.cheatSheets.windowTitle}"
|
||||
Width="480" Height="820" MinWidth="320" MinHeight="300"
|
||||
CanResize="True"
|
||||
Topmost="{Binding IsPinned}"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.cheatSheets.title}" CloseCommand="{Binding CloseCommand}">
|
||||
<DockPanel>
|
||||
<!-- Sheet picker + pin. Pin keeps the window above the shell while it sits at a screen edge. -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,10,16,0">
|
||||
<ComboBox ItemsSource="{Binding Sheets}"
|
||||
SelectedItem="{Binding SelectedSheet}"
|
||||
DisplayMemberBinding="{Binding Title}"
|
||||
MinWidth="180"
|
||||
VerticalAlignment="Center"/>
|
||||
<ToggleButton Content="{loc:Tr modals.cheatSheets.pin}"
|
||||
IsChecked="{Binding IsPinned}"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{loc:Tr modals.cheatSheets.pinTooltip}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<!-- Inset on the content, not the ScrollViewer: Avalonia 12 leaves ScrollViewer.Padding
|
||||
out of the Extent, which clips the last lines at max scroll. -->
|
||||
<ctl:MarkdownView Markdown="{Binding SelectedSheet.Body}" CommandRows="True"
|
||||
Margin="16,12,16,16"/>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class CheatSheetsWindow : Window
|
||||
{
|
||||
public CheatSheetsWindow() => InitializeComponent();
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -252,8 +252,8 @@
|
||||
<TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
|
||||
<!-- PowerUse itself is toggled on the usage monitor; only its handoff budget is
|
||||
typed here, because it also applies to a normal (interactive) handler run. -->
|
||||
<!-- Outside the PowerUse section below on purpose: the handoff budget also bounds
|
||||
a normal (interactive) handler run. -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.listHandlerMaxHandoffs}"/>
|
||||
<NumericUpDown Value="{Binding General.ListHandlerMaxHandoffs, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
@@ -264,6 +264,34 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- PowerUse: a master switch plus its time window. Never sufficient on its own —
|
||||
the run's list must also allow PowerUse in its list settings. -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.powerUseHeading}"/>
|
||||
<CheckBox IsChecked="{Binding General.PowerUseEnabled, Mode=TwoWay}"
|
||||
Content="{loc:Tr settings.general.powerUse}"/>
|
||||
<TextBlock Text="{loc:Tr settings.general.powerUseHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
|
||||
<TextBlock Classes="eyebrow" VerticalAlignment="Center"
|
||||
Text="{loc:Tr settings.general.powerUseWindow}"/>
|
||||
<TextBox Width="70" FontSize="12" Padding="6,2"
|
||||
Watermark="{loc:Tr settings.general.powerUseWindowPlaceholder}"
|
||||
Text="{Binding General.PowerUseWindowStart, Mode=TwoWay}"/>
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center" Text="–"/>
|
||||
<TextBox Width="70" FontSize="12" Padding="6,2"
|
||||
Watermark="{loc:Tr settings.general.powerUseWindowPlaceholder}"
|
||||
Text="{Binding General.PowerUseWindowEnd, Mode=TwoWay}"/>
|
||||
<TextBlock Classes="eyebrow" VerticalAlignment="Center" Margin="12,0,0,0"
|
||||
Text="{loc:Tr settings.general.powerUseHandoffPct}"/>
|
||||
<NumericUpDown Width="80" FontSize="12" Padding="6,2" ShowButtonSpinner="False"
|
||||
Minimum="50" Maximum="99" Increment="1" FormatString="0"
|
||||
Value="{Binding General.PowerUseHandoffPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{loc:Tr settings.general.powerUseWindowHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage);
|
||||
echoed read-only here so they aren't invisible on the tab that owns the gate. -->
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
</Window.KeyBindings>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
|
||||
<!-- Refresh sits in the titlebar: the worker polls on a slow cadence to avoid the endpoint's
|
||||
429s, so this is the way to get a number now — but it doesn't earn a row in the body. -->
|
||||
<ctl:ModalShell.HeaderActions>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Ellipse Classes="spinner" Width="12" Height="12" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsRefreshing}"/>
|
||||
<Button Classes="icon-btn" Content="⟳" FontSize="{DynamicResource FontSizeBody}"
|
||||
Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsRefreshing}"
|
||||
ToolTip.Tip="{loc:Tr modals.usageMonitor.refreshHint}"/>
|
||||
</StackPanel>
|
||||
</ctl:ModalShell.HeaderActions>
|
||||
<DockPanel>
|
||||
|
||||
<!-- Stale / gate bands -->
|
||||
@@ -47,18 +59,6 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Refresh: the worker polls on a slow cadence to avoid the endpoint's 429s -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8"
|
||||
Margin="20,12,20,0" VerticalAlignment="Center">
|
||||
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.refresh}"
|
||||
Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsRefreshing}"/>
|
||||
<Ellipse Classes="spinner" Width="14" Height="14" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsRefreshing}"/>
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center"
|
||||
Text="{loc:Tr modals.usageMonitor.refreshHint}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Gauges -->
|
||||
<ItemsControl DockPanel.Dock="Top" Margin="20,12,20,0" ItemsSource="{Binding GaugeRows}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -134,19 +134,6 @@
|
||||
Text="{loc:Tr modals.usageMonitor.noGauges}"
|
||||
IsVisible="{Binding !GaugeRows.Count}"/>
|
||||
|
||||
<!-- PowerUse: sits under the gauges on purpose — "spend the rest of the window" is a
|
||||
decision made while looking at how much window is left. Saves immediately on toggle. -->
|
||||
<Border DockPanel.Dock="Top" Margin="20,12,20,0" Padding="12,10"
|
||||
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6">
|
||||
<StackPanel Spacing="4">
|
||||
<CheckBox IsChecked="{Binding PowerUseEnabled, Mode=TwoWay}"
|
||||
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
|
||||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||||
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Period picker -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/>
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class WindowDialogService : IDialogService
|
||||
private readonly Window _owner;
|
||||
private MissionControlWindow? _missionControl;
|
||||
private DiffViewerView? _diffViewerWindow;
|
||||
private CheatSheetsWindow? _cheatSheets;
|
||||
|
||||
public WindowDialogService(Window owner) => _owner = owner;
|
||||
|
||||
@@ -182,9 +183,29 @@ public sealed class WindowDialogService : IDialogService
|
||||
_missionControl ??= new MissionControlWindow { DataContext = vm };
|
||||
if (!_missionControl.IsVisible)
|
||||
_missionControl.Show(); // modeless, independent top-level window
|
||||
// Activate() alone does not restore a minimised window — it stays in the taskbar and the
|
||||
// button looks dead. Un-minimise first, then take focus.
|
||||
if (_missionControl.WindowState == WindowState.Minimized)
|
||||
_missionControl.WindowState = WindowState.Normal;
|
||||
_missionControl.Activate(); // bring to front / focus
|
||||
}
|
||||
|
||||
public void ShowCheatSheets(CheatSheetsViewModel vm)
|
||||
{
|
||||
// Modeless and single-instance, like Mission Control: the point is to park it beside the
|
||||
// shell, so a second window (or a modal that blocks the shell) would defeat it.
|
||||
if (_cheatSheets is null)
|
||||
{
|
||||
_cheatSheets = new CheatSheetsWindow { DataContext = vm };
|
||||
vm.CloseAction = () => _cheatSheets?.Close();
|
||||
_cheatSheets.Closed += (_, _) => _cheatSheets = null;
|
||||
_cheatSheets.Show(_owner);
|
||||
}
|
||||
if (_cheatSheets.WindowState == WindowState.Minimized)
|
||||
_cheatSheets.WindowState = WindowState.Normal;
|
||||
_cheatSheets.Activate();
|
||||
}
|
||||
|
||||
public Task<bool> ConfirmAsync(string message)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
+21
-5
@@ -11,7 +11,11 @@ namespace ClaudeDo.Worker.External;
|
||||
// Coerced=true means nextPhase asked for a non-final phase but the server-side round cap
|
||||
// (HandoffRoundTracker) forced it to its "_final" counterpart instead -- NextPhase already
|
||||
// reflects the effective (post-coercion) value in that case.
|
||||
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false);
|
||||
// DeferredUntil set means the next session was NOT opened yet: the 5h usage window is exhausted and
|
||||
// the handoff is parked until it resets (HandlerUsageGuard).
|
||||
public sealed record HandoffListHandlerResult(
|
||||
bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false,
|
||||
DateTimeOffset? DeferredUntil = null);
|
||||
|
||||
public sealed record HandoffStatusResult(string TaskId, int Number, int Round, int Remaining, bool Final);
|
||||
|
||||
@@ -22,17 +26,20 @@ public sealed class HandoffMcpTools
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly HandoffRoundTracker _rounds;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly HandlerUsageGuard _usageGuard;
|
||||
|
||||
public HandoffMcpTools(
|
||||
TaskRepository tasks,
|
||||
HubBroadcaster broadcaster,
|
||||
HandoffRoundTracker rounds,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
HandlerUsageGuard usageGuard)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
_rounds = rounds;
|
||||
_dbFactory = dbFactory;
|
||||
_usageGuard = usageGuard;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -47,8 +54,10 @@ public sealed class HandoffMcpTools
|
||||
"4 by default, i.e. two " +
|
||||
"full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " +
|
||||
"silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " +
|
||||
"any handoff at all after a merge_final round for this task throws. The current tile stays " +
|
||||
"open; you must end your own turn immediately after calling this.")]
|
||||
"any handoff at all after a merge_final round for this task throws. If the 5h usage window is " +
|
||||
"already exhausted the next session is NOT opened right away -- the handoff is parked until " +
|
||||
"the window resets and the result carries deferredUntil; say so in your summary. The current " +
|
||||
"tile stays open; you must end your own turn immediately after calling this either way.")]
|
||||
public async Task<HandoffListHandlerResult> HandoffListHandler(
|
||||
[Description("This session's own handler task id.")] string taskId,
|
||||
[Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList<string> survivingTaskIds,
|
||||
@@ -77,8 +86,15 @@ public sealed class HandoffMcpTools
|
||||
if (round.Rejected)
|
||||
throw new InvalidOperationException(round.Error);
|
||||
|
||||
// The round is registered either way (the budget is spent by asking, not by the tile
|
||||
// opening); only the broadcast waits when the 5h window is exhausted.
|
||||
var deferredUntil = await _usageGuard.TryDeferHandoffAsync(
|
||||
taskId, survivingTaskIds, round.EffectivePhase, cancellationToken);
|
||||
if (deferredUntil is null)
|
||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
||||
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced);
|
||||
|
||||
return new HandoffListHandlerResult(
|
||||
true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers);
|
||||
|
||||
// The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts;
|
||||
// the App owns the ConPTY tile and creates the handler task exactly as the button does
|
||||
// (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for
|
||||
// starting a run, not two. Without a connected App nothing happens -- the result says so.
|
||||
[McpServerToolType]
|
||||
public sealed class ListHandlerMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
|
||||
{
|
||||
_lists = lists;
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " +
|
||||
"Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " +
|
||||
"ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " +
|
||||
"Requires the App to be running and connected to this worker -- the worker only broadcasts " +
|
||||
"the request. taskIds accept task numbers (#123 or bare 123).")]
|
||||
public async Task<StartListHandlerResult> StartListHandler(
|
||||
[Description("The list to run the handler on.")] string listId,
|
||||
[Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList<string>? taskIds = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
var listTasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
|
||||
List<TaskEntity> chosen;
|
||||
if (taskIds is { Count: > 0 })
|
||||
{
|
||||
var resolved = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
|
||||
chosen = new List<TaskEntity>();
|
||||
foreach (var id in resolved)
|
||||
{
|
||||
var t = listTasks.FirstOrDefault(x => x.Id == id)
|
||||
?? throw new InvalidOperationException($"Task {id} not found in list {list.Name}.");
|
||||
chosen.Add(t);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
chosen = listTasks
|
||||
.Where(t => t.Status == TaskStatus.Idle && !t.IsManual && t.ParentTaskId is null)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (chosen.Count == 0)
|
||||
throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}.");
|
||||
|
||||
var ids = chosen.Select(t => t.Id).ToList();
|
||||
await _broadcaster.ListHandlerStartRequested(listId, ids);
|
||||
return new StartListHandlerResult(true, listId, list.Name, ids.Count, chosen.Select(t => t.Number).ToList());
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,16 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
||||
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
|
||||
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
|
||||
|
||||
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage
|
||||
// tile, exactly like the "Let Claude handle it" button.
|
||||
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
|
||||
_hub.Clients.All.SendAsync("ListHandlerStartRequested", listId, taskIds);
|
||||
// The 5h usage window is about to run out under a running list-handler session (HandlerUsageGuard).
|
||||
// Mission Control types `message` straight into that task's ConPTY pane, so the session can hand
|
||||
// off cleanly instead of dying mid-merge. No pane for the task = nothing happens.
|
||||
public Task HandlerUsageWarning(string handlerTaskId, string message) =>
|
||||
_hub.Clients.All.SendAsync("HandlerUsageWarning", handlerTaskId, message);
|
||||
|
||||
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
|
||||
public Task NotesUpdated() =>
|
||||
_hub.Clients.All.SendAsync("NotesUpdated");
|
||||
|
||||
@@ -72,6 +72,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly TicketPatStore _ticketPat;
|
||||
private readonly TicketClientFactory _ticketClients;
|
||||
private readonly TicketImportService _ticketImport;
|
||||
private readonly HandlerRestartCoordinator? _handlerRestart;
|
||||
private readonly HandlerRoundState? _roundState;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -110,7 +112,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
TicketSystemConfig ticketConfig = null!,
|
||||
TicketPatStore ticketPat = null!,
|
||||
TicketClientFactory ticketClients = null!,
|
||||
TicketImportService ticketImport = null!)
|
||||
TicketImportService ticketImport = null!,
|
||||
HandlerRestartCoordinator? handlerRestart = null,
|
||||
HandlerRoundState? roundState = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -149,6 +153,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_ticketPat = ticketPat;
|
||||
_ticketClients = ticketClients;
|
||||
_ticketImport = ticketImport;
|
||||
_handlerRestart = handlerRestart;
|
||||
_roundState = roundState;
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
@@ -323,7 +329,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
row.UsageThrottleSevenDayHardPct,
|
||||
row.AutoContinueOnUsageLimit,
|
||||
row.PowerUseEnabled,
|
||||
row.ListHandlerMaxHandoffs);
|
||||
row.ListHandlerMaxHandoffs,
|
||||
row.PowerUseWindowStart,
|
||||
row.PowerUseWindowEnd,
|
||||
row.PowerUseHandoffPct);
|
||||
}
|
||||
|
||||
public async Task UpdateAppSettings(AppSettingsDto dto)
|
||||
@@ -363,6 +372,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
// The cap is what bounds a runaway handoff loop; a 0 from a stale or hand-written
|
||||
// client would remove that bound entirely, so clamp instead of trusting the wire value.
|
||||
ListHandlerMaxHandoffs = Math.Max(1, dto.ListHandlerMaxHandoffs),
|
||||
// An unparseable time string is stored as null (= no window) rather than rejected: the
|
||||
// window is an optional narrowing, and a typo must not silently mean "PowerUse never
|
||||
// applies". Below 50% a handoff threshold would fire before the session got anywhere.
|
||||
PowerUseWindowStart = PowerUseWindow.Normalize(dto.PowerUseWindowStart),
|
||||
PowerUseWindowEnd = PowerUseWindow.Normalize(dto.PowerUseWindowEnd),
|
||||
PowerUseHandoffPct = Math.Clamp(dto.PowerUseHandoffPct, 50, 99),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -786,6 +801,36 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, nextPhase, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Reports that an embedded ConPTY session's process exited. Only the UI process owns the
|
||||
// terminal, so the exit code exists nowhere else; every decision about it (was this a list
|
||||
// handler, did the usage limit kill it, is PowerUse on, when does the window reset) belongs to
|
||||
// HandlerRestartCoordinator. Fire-and-forget on purpose: the coordinator's task stays parked
|
||||
// until the 5h reset, which is far longer than this connection or this hub instance lives, so
|
||||
// it must not run under Context.ConnectionAborted either.
|
||||
public void ReportInteractiveSessionExited(string taskId, int exitCode)
|
||||
{
|
||||
if (_handlerRestart is null) return;
|
||||
_ = _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
|
||||
|
||||
@@ -78,7 +78,11 @@ builder.Services.AddHostedService<AttachmentOrphanRecovery>();
|
||||
builder.Services.AddHostedService<PromptFileRecovery>();
|
||||
builder.Services.AddHostedService<LegacyWorktreeFolderRecovery>();
|
||||
builder.Services.AddHostedService<UntrackedWorktreeRecovery>();
|
||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||
// 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());
|
||||
});
|
||||
@@ -95,6 +99,10 @@ builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
||||
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
||||
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
||||
builder.Services.AddSingleton<HandoffRoundTracker>();
|
||||
builder.Services.AddSingleton<HandlerRoundState>();
|
||||
builder.Services.AddSingleton<HandlerRestartCoordinator>();
|
||||
builder.Services.AddSingleton<HandlerUsageGuard>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<HandlerUsageGuard>());
|
||||
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
|
||||
builder.Services.AddSingleton<TaskRunner>();
|
||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||
@@ -337,6 +345,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
Share<AttachmentStore>();
|
||||
Share<FindingsStore>();
|
||||
Share<HandoffRoundTracker>();
|
||||
Share<HandlerUsageGuard>();
|
||||
|
||||
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
@@ -355,6 +364,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
||||
externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
||||
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
|
||||
@@ -375,6 +385,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
.WithTools<AgentMcpTools>()
|
||||
.WithTools<LifecycleMcpTools>()
|
||||
.WithTools<HandoffMcpTools>()
|
||||
.WithTools<ListHandlerMcpTools>()
|
||||
.WithTools<AppSettingsMcpTools>()
|
||||
.WithTools<TaskWaitMcpTools>()
|
||||
.WithTools<QueueStateMcpTools>()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// Reopens a list-handler ConPTY session that died on the Anthropic usage limit, once the 5h window
|
||||
/// resets — the interactive counterpart to <c>UsageLimitAutoContinueCoordinator</c>, which only ever
|
||||
/// sees headless task runs. Deliberately a sibling and not an extension of it: a ConPTY session has
|
||||
/// no <c>task_runs</c> row, no <c>FailureReason</c>, and is resumed by relaunching a phase rather
|
||||
/// than by <c>ContinueTask</c> through the override slot.
|
||||
///
|
||||
/// Driven by the UI (<c>WorkerHub.ReportInteractiveSessionExited</c>) because the exit code only
|
||||
/// exists in the process that owns the terminal. Everything else — was it the usage limit, is
|
||||
/// PowerUse on, which phase and task set, when does the window reset — is decided here.
|
||||
///
|
||||
/// Fails CLOSED, unlike the usage gate: every missing signal means "don't relaunch". An interactive
|
||||
/// session that pops back up at 3am uninvited is worse than one that stays dead.
|
||||
///
|
||||
/// It does not wait here. The restart is <b>parked in <see cref="HandlerRoundState"/></b> and fired
|
||||
/// by <see cref="HandlerUsageGuard"/> — the same slot a session that handed off cleanly before the
|
||||
/// limit uses, so exactly one of the two reopens a round. Parking it also survives a worker restart
|
||||
/// during the hours-long wait, which an in-process <c>Task.Delay</c> did not.
|
||||
/// </summary>
|
||||
public sealed class HandlerRestartCoordinator
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly UsageState _usageState;
|
||||
private readonly HandlerRoundState _rounds;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ILogger<HandlerRestartCoordinator> _logger;
|
||||
|
||||
public HandlerRestartCoordinator(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
UsageState usageState,
|
||||
HandlerRoundState rounds,
|
||||
HubBroadcaster broadcaster,
|
||||
ILogger<HandlerRestartCoordinator> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_usageState = usageState;
|
||||
_rounds = rounds;
|
||||
_broadcaster = broadcaster;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Handles a ConPTY session's process exit. Returns true when a restart was parked for
|
||||
/// the reset (HandlerUsageGuard fires it). Any non-handler task, clean exit, non-usage-limit
|
||||
/// death, PowerUse-off list, already-used round, or round that already has a handoff parked is a
|
||||
/// silent no-op.</summary>
|
||||
public async Task<bool> OnSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||
{
|
||||
if (exitCode == 0 || string.IsNullOrEmpty(taskId)) return false;
|
||||
|
||||
var round = _rounds.Get(taskId);
|
||||
// Not a list-handler session at all (a plain interactive task pane), or the initial triage
|
||||
// session, which has no handoff launch spec to reopen -- BuildForMergeHelperAsync needs the
|
||||
// list id and the original selection, and its dedupe/enhance work is not idempotent.
|
||||
if (round is null || !MergeHelperPhase.All.Contains(round.Phase, StringComparer.Ordinal)) return false;
|
||||
|
||||
var snapshot = _usageState.Snapshot;
|
||||
if (snapshot?.FiveHour is not { } fiveHour) return false;
|
||||
if (fiveHour.ResetsAt is not { } resetsAt) return false;
|
||||
|
||||
int number;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct);
|
||||
if (task is null) return false;
|
||||
number = task.Number;
|
||||
|
||||
// Both PowerUse keys, exactly as ComposeHandlerPromptAsync gates the PowerUse prompt
|
||||
// addendum: the global switch alone must not make an interactive run on some unrelated
|
||||
// list restart itself overnight.
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||
if (!settings.PowerUseEnabled) return false;
|
||||
if ((await new ListRepository(ctx).GetConfigAsync(task.ListId, ct))?.PowerUseAllowed != true) return false;
|
||||
|
||||
// The corroborating signal that this exit really was the usage limit. A ConPTY session
|
||||
// gives us nothing but an exit code -- there is no stdout to match against, so
|
||||
// TaskRunner's IsUsageLimitMessage regex (which reads the CLI's own "session limit
|
||||
// reached" text) has nothing to read here. The 5h utilization at the moment of death is
|
||||
// the one independent witness we do have. Same threshold the warning and the withheld
|
||||
// handoff use (PowerUseHandoffPct) so all three agree on where "at the limit" starts;
|
||||
// a crash or a manual Ctrl-C well below it is NOT restarted.
|
||||
if (fiveHour.Utilization < Math.Clamp(settings.PowerUseHandoffPct, 50, 99)) return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HandlerRestartCoordinator: could not evaluate handler task {TaskId}; not restarting", taskId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// One restart per round, and never on top of a handoff the session itself already parked.
|
||||
// The budget is NOT spent here -- HandlerUsageGuard spends it when the broadcast goes out,
|
||||
// so a worker that dies during the wait doesn't leave the round marked "restart used".
|
||||
var dueAt = resetsAt + HandlerUsageGuard.ResetGrace;
|
||||
if (!_rounds.TryScheduleAutoRestart(taskId, dueAt, round.Phase, round.TaskIds)) return false;
|
||||
|
||||
// Fire-and-forget caller (WorkerHub): an unobserved exception here would be invisible, so
|
||||
// the log line must not be able to throw past this point.
|
||||
try
|
||||
{
|
||||
await _broadcaster.WorkerLog(
|
||||
$"List handler #{number} died at the usage limit (5h {fiveHour.Utilization:0}%) — " +
|
||||
$"restarting phase {round.Phase} after the reset, at {dueAt.ToLocalTime():HH:mm}",
|
||||
WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HandlerRestartCoordinator: restart of #{Number} is parked, but the WorkerLog broadcast failed", number);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
/// <summary>One list-handler round: the phase a session is running and the exact task set it was
|
||||
/// handed. Written every time a handler session is built, so an outside observer (a crashed tile,
|
||||
/// a deferred handoff) can reopen the SAME phase with the SAME set without the in-flight
|
||||
/// <c>HandoffRequested</c> broadcast that is the only other place these ids exist.</summary>
|
||||
/// <param name="Phase">A <see cref="MergeHelperPhase"/> value, or "triage" for the initial session
|
||||
/// (which has no handoff spec and therefore cannot be reopened — see HandlerRestartCoordinator).</param>
|
||||
/// <param name="AutoRestartUsed">Spent once per round, and only once the deferred
|
||||
/// <c>HandoffRequested</c> broadcast has actually gone out — a restart that is merely *scheduled*
|
||||
/// must not count, or a worker that dies during the hours-long wait leaves the round permanently
|
||||
/// marked "restart used" without one ever having happened.</param>
|
||||
/// <param name="DeferredHandoffAt">Due time of a handoff parked until the 5h usage window resets.
|
||||
/// One slot per round, shared by both producers: a session that handed off cleanly just before the
|
||||
/// limit (HandlerUsageGuard) and a session that died at it (HandlerRestartCoordinator) — so the two
|
||||
/// can never reopen the same round twice. Fired by HandlerUsageGuard, which re-picks it up after a
|
||||
/// worker restart because it lives in this file rather than in a timer.</param>
|
||||
public sealed record HandlerRound(
|
||||
string HandlerTaskId,
|
||||
string Phase,
|
||||
IReadOnlyList<string> TaskIds,
|
||||
DateTime StartedAtUtc,
|
||||
bool AutoRestartUsed = false,
|
||||
DateTimeOffset? DeferredHandoffAt = null,
|
||||
string? DeferredPhase = null,
|
||||
IReadOnlyList<string>? DeferredTaskIds = null,
|
||||
bool DeferredIsAutoRestart = false,
|
||||
bool UsageWarningSent = false);
|
||||
|
||||
/// <summary>
|
||||
/// File-backed store for <see cref="HandlerRound"/>, one entry per handler task. Deliberately a
|
||||
/// JSON file under <c>~/.claudeDo/</c> (same Load/Save shape as <c>ClaudeDo.Ui.AppSettings</c>)
|
||||
/// rather than a DB column: rounds are process-lifetime scratch state that no query joins against,
|
||||
/// and an EF migration here would collide with the one the settings work owns this round.
|
||||
/// Every read/write goes through one lock and re-serializes the whole file — there are at most a
|
||||
/// handful of concurrent handler runs, so nothing here needs to be clever.
|
||||
/// </summary>
|
||||
public sealed class HandlerRoundState
|
||||
{
|
||||
// Entries older than this are dropped on the next save. A round that has been open for a week
|
||||
// is not coming back, and without a sweep the file only ever grows.
|
||||
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly object _lock = new();
|
||||
private Dictionary<string, HandlerRound>? _rounds;
|
||||
|
||||
public HandlerRoundState() : this(Path.Combine(Paths.AppDataRoot(), "handler-rounds.json"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — point the store at a temp file instead of the live app data root.</summary>
|
||||
internal HandlerRoundState(string path) => _path = path;
|
||||
|
||||
public HandlerRound? Get(string handlerTaskId)
|
||||
{
|
||||
lock (_lock)
|
||||
return Rounds().GetValueOrDefault(handlerTaskId);
|
||||
}
|
||||
|
||||
/// <summary>Overwrites this handler task's round. <see cref="HandlerRound.AutoRestartUsed"/> is
|
||||
/// carried over when the phase is unchanged: the auto-restart itself rebuilds the same phase's
|
||||
/// launch spec, which lands right back here, and a naive overwrite would hand the round a fresh
|
||||
/// restart budget every time — exactly the all-night relaunch loop the claim exists to prevent.
|
||||
/// A genuinely new phase is a new round and starts with a fresh budget.</summary>
|
||||
public void Save(HandlerRound round)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var rounds = Rounds();
|
||||
if (rounds.TryGetValue(round.HandlerTaskId, out var previous)
|
||||
&& string.Equals(previous.Phase, round.Phase, StringComparison.Ordinal)
|
||||
&& previous.AutoRestartUsed)
|
||||
{
|
||||
round = round with { AutoRestartUsed = true };
|
||||
}
|
||||
|
||||
rounds[round.HandlerTaskId] = round;
|
||||
Persist(rounds);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<HandlerRound> All()
|
||||
{
|
||||
lock (_lock)
|
||||
return Rounds().Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Claims this round's single "warn the session before the usage limit" shot. True only
|
||||
/// the first time, so a monitor that keeps ticking above the threshold does not retype the same
|
||||
/// warning into the session every 30 seconds.</summary>
|
||||
public bool TryMarkUsageWarned(string handlerTaskId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var rounds = Rounds();
|
||||
if (!rounds.TryGetValue(handlerTaskId, out var round) || round.UsageWarningSent) return false;
|
||||
|
||||
rounds[handlerTaskId] = round with { UsageWarningSent = true };
|
||||
Persist(rounds);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parks a handoff the running session asked for until <paramref name="dueAt"/>.
|
||||
/// Overwrites any existing slot: the live session's own request is the most recent word on what
|
||||
/// the next round should be.</summary>
|
||||
public void SetDeferredHandoff(
|
||||
string handlerTaskId, DateTimeOffset dueAt, string phase, IReadOnlyList<string> taskIds)
|
||||
=> WriteDeferred(handlerTaskId, dueAt, phase, taskIds, isAutoRestart: false);
|
||||
|
||||
/// <summary>Schedules this round's single auto-restart (the session died at the limit). False
|
||||
/// when there is no round, the restart budget is already spent, or a handoff is *already* parked
|
||||
/// for this round — the deferred handoff and the auto-restart are one trigger, not two.
|
||||
/// <see cref="HandlerRound.AutoRestartUsed"/> is deliberately NOT set here; only
|
||||
/// <see cref="ClearDeferredHandoff"/> spends it, once the broadcast really went out.</summary>
|
||||
public bool TryScheduleAutoRestart(
|
||||
string handlerTaskId, DateTimeOffset dueAt, string phase, IReadOnlyList<string> taskIds)
|
||||
{
|
||||
lock (_lock) // Monitor is reentrant — WriteDeferred takes the same lock.
|
||||
{
|
||||
var rounds = Rounds();
|
||||
if (!rounds.TryGetValue(handlerTaskId, out var round)) return false;
|
||||
if (round.AutoRestartUsed || round.DeferredHandoffAt is not null) return false;
|
||||
|
||||
WriteDeferred(handlerTaskId, dueAt, phase, taskIds, isAutoRestart: true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the parked handoff. <paramref name="spendAutoRestart"/> marks the round's
|
||||
/// restart budget as used — pass the slot's own <see cref="HandlerRound.DeferredIsAutoRestart"/>,
|
||||
/// so a clean pre-limit handoff never costs the next session its restart.</summary>
|
||||
public void ClearDeferredHandoff(string handlerTaskId, bool spendAutoRestart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var rounds = Rounds();
|
||||
if (!rounds.TryGetValue(handlerTaskId, out var round)) return;
|
||||
|
||||
rounds[handlerTaskId] = round with
|
||||
{
|
||||
DeferredHandoffAt = null,
|
||||
DeferredPhase = null,
|
||||
DeferredTaskIds = null,
|
||||
DeferredIsAutoRestart = false,
|
||||
AutoRestartUsed = round.AutoRestartUsed || spendAutoRestart,
|
||||
};
|
||||
Persist(rounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteDeferred(
|
||||
string handlerTaskId, DateTimeOffset dueAt, string phase, IReadOnlyList<string> taskIds, bool isAutoRestart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var rounds = Rounds();
|
||||
if (!rounds.TryGetValue(handlerTaskId, out var round)) return;
|
||||
|
||||
rounds[handlerTaskId] = round with
|
||||
{
|
||||
DeferredHandoffAt = dueAt,
|
||||
DeferredPhase = phase,
|
||||
DeferredTaskIds = taskIds.ToList(),
|
||||
DeferredIsAutoRestart = isAutoRestart,
|
||||
};
|
||||
Persist(rounds);
|
||||
}
|
||||
}
|
||||
|
||||
public void Remove(string handlerTaskId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var rounds = Rounds();
|
||||
if (rounds.Remove(handlerTaskId)) Persist(rounds);
|
||||
}
|
||||
}
|
||||
|
||||
// Callers hold _lock.
|
||||
private Dictionary<string, HandlerRound> Rounds()
|
||||
{
|
||||
if (_rounds is not null) return _rounds;
|
||||
try
|
||||
{
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
_rounds = JsonSerializer.Deserialize<Dictionary<string, HandlerRound>>(
|
||||
File.ReadAllText(_path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A corrupt/unreadable file must never block a handler launch — start empty.
|
||||
}
|
||||
return _rounds ??= new Dictionary<string, HandlerRound>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private void Persist(Dictionary<string, HandlerRound> rounds)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - Retention;
|
||||
foreach (var stale in rounds.Where(kv => kv.Value.StartedAtUtc < cutoff).Select(kv => kv.Key).ToList())
|
||||
rounds.Remove(stale);
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(_path, JsonSerializer.Serialize(rounds, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Losing persistence costs an auto-restart across a worker restart, never a launch.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// Hands a PowerUse list-handler round over cleanly across the 5h usage limit, instead of letting it
|
||||
/// die mid-merge:
|
||||
///
|
||||
/// 1. <b>Warn</b> — once per round, as soon as the 5h window crosses
|
||||
/// <c>AppSettings.PowerUseHandoffPct</c>, a message is typed straight into the live ConPTY session
|
||||
/// (<c>HandlerUsageWarning</c> → Mission Control) telling it to finish what it is doing and call
|
||||
/// <c>handoff_list_handler</c> itself. A session that hands off on its own leaves no half-merged
|
||||
/// tree behind; an auto-restart after the fact cannot clean one up.
|
||||
/// 2. <b>Withhold</b> — the resulting handoff is not broadcast immediately (the next session would
|
||||
/// open straight into the same exhausted window) but parked in <see cref="HandlerRoundState"/>
|
||||
/// until the window resets. Same slot the auto-restart of a session that *did* die uses
|
||||
/// (<see cref="HandlerRestartCoordinator"/>), so the two can never reopen one round twice.
|
||||
/// 3. <b>Fire</b> — this service's tick is also what releases a parked handoff. Deliberately a poll
|
||||
/// over the persisted state rather than a timer: a worker restart mid-wait re-picks the parked
|
||||
/// handoff up on the next tick, and an overdue one goes out immediately, for free.
|
||||
/// </summary>
|
||||
public sealed class HandlerUsageGuard : BackgroundService
|
||||
{
|
||||
internal static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Small cushion past ResetsAt: the reported reset time is the server's, and reopening a session
|
||||
// one second early buys nothing but another dead tile.
|
||||
internal static readonly TimeSpan ResetGrace = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly UsageState _usageState;
|
||||
private readonly HandlerRoundState _rounds;
|
||||
private readonly HandoffRoundTracker _handoffRounds;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ILogger<HandlerUsageGuard> _logger;
|
||||
|
||||
public HandlerUsageGuard(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
UsageState usageState,
|
||||
HandlerRoundState rounds,
|
||||
HandoffRoundTracker handoffRounds,
|
||||
HubBroadcaster broadcaster,
|
||||
ILogger<HandlerUsageGuard> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_usageState = usageState;
|
||||
_rounds = rounds;
|
||||
_handoffRounds = handoffRounds;
|
||||
_broadcaster = broadcaster;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Test seam — a real 5h wait is not a test.</summary>
|
||||
internal Func<DateTimeOffset> NowUtc { get; set; } = () => DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>PowerUse verdict for one handler task: both PowerUse switches on, a 5h bucket
|
||||
/// available, plus the configured threshold and the task's display number.</summary>
|
||||
internal sealed record Policy(int HandoffPct, UsageBucket FiveHour, int Number);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync(stoppingToken);
|
||||
await Task.Delay(TickInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HandlerUsageGuard: tick failed");
|
||||
await Task.Delay(TickInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task TickAsync(CancellationToken ct)
|
||||
{
|
||||
foreach (var round in _rounds.All())
|
||||
{
|
||||
await FireIfDueAsync(round);
|
||||
await WarnIfNearLimitAsync(round, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called by <c>handoff_list_handler</c>. Returns the time the handoff was parked until
|
||||
/// when it must be withheld, or null when it should go out now (PowerUse off for this list, well
|
||||
/// below the threshold, or no reset time to wait for).</summary>
|
||||
public async Task<DateTimeOffset?> TryDeferHandoffAsync(
|
||||
string handlerTaskId, IReadOnlyList<string> survivingTaskIds, string phase, CancellationToken ct)
|
||||
{
|
||||
var policy = await EvaluateAsync(handlerTaskId, ct);
|
||||
if (policy is null) return null;
|
||||
|
||||
// The warning flag matters on its own: utilization can dip back under the threshold between
|
||||
// the warning and the session actually finishing its merge, and a handoff produced *because*
|
||||
// of the warning must still wait for the reset.
|
||||
var warned = _rounds.Get(handlerTaskId)?.UsageWarningSent == true;
|
||||
if (policy.FiveHour.Utilization < policy.HandoffPct && !warned) return null;
|
||||
|
||||
if (policy.FiveHour.ResetsAt is not { } resetsAt)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"HandlerUsageGuard: 5h window at {Pct}% has no reset time; handing off {TaskId} immediately",
|
||||
policy.FiveHour.Utilization, handlerTaskId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var dueAt = resetsAt + ResetGrace;
|
||||
_rounds.SetDeferredHandoff(handlerTaskId, dueAt, phase, survivingTaskIds);
|
||||
await _broadcaster.WorkerLog(
|
||||
$"List handler #{policy.Number}: handoff to phase {phase} withheld until " +
|
||||
$"{dueAt.ToLocalTime():HH:mm} (5h window at {policy.FiveHour.Utilization:0}%)",
|
||||
WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
return dueAt;
|
||||
}
|
||||
|
||||
private async Task FireIfDueAsync(HandlerRound round)
|
||||
{
|
||||
if (round.DeferredHandoffAt is not { } dueAt || dueAt > NowUtc()) return;
|
||||
|
||||
if (round.DeferredPhase is not { Length: > 0 } phase || round.DeferredTaskIds is not { Count: > 0 } taskIds)
|
||||
{
|
||||
_rounds.ClearDeferredHandoff(round.HandlerTaskId, spendAutoRestart: false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleared BEFORE the broadcast so a throw on the way out can't leave a slot that re-fires on
|
||||
// every tick; the restart budget is spent here too, which is the point of the whole
|
||||
// rework -- it is now only spent once a handoff really went out.
|
||||
_rounds.ClearDeferredHandoff(round.HandlerTaskId, spendAutoRestart: round.DeferredIsAutoRestart);
|
||||
|
||||
await _broadcaster.HandoffRequested(round.HandlerTaskId, taskIds, phase);
|
||||
await _broadcaster.WorkerLog(
|
||||
$"Reopening list handler at phase {phase} after the usage-limit reset",
|
||||
WorkerLogLevel.Info, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private async Task WarnIfNearLimitAsync(HandlerRound round, CancellationToken ct)
|
||||
{
|
||||
if (round.UsageWarningSent) return;
|
||||
// A sealed chain has no further handoff to ask for -- telling it to call
|
||||
// handoff_list_handler would only earn the session an exception.
|
||||
if (_handoffRounds.GetState(round.HandlerTaskId).Sealed) return;
|
||||
|
||||
var policy = await EvaluateAsync(round.HandlerTaskId, ct);
|
||||
if (policy is null || policy.FiveHour.Utilization < policy.HandoffPct) return;
|
||||
if (!_rounds.TryMarkUsageWarned(round.HandlerTaskId)) return;
|
||||
|
||||
await _broadcaster.HandlerUsageWarning(
|
||||
round.HandlerTaskId, BuildWarning(round.HandlerTaskId, round.Phase, policy.FiveHour.Utilization));
|
||||
await _broadcaster.WorkerLog(
|
||||
$"List handler #{policy.Number}: warned the session at 5h {policy.FiveHour.Utilization:0}% " +
|
||||
$"(handoff threshold {policy.HandoffPct}%)",
|
||||
WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// Plain ASCII on purpose: this is typed character-by-character into a PTY, not rendered by us.
|
||||
internal static string BuildWarning(string handlerTaskId, string phase, double utilization) =>
|
||||
$"USAGE LIMIT IMMINENT ({utilization:0}% of the 5h window). Finish or abort the merge you are " +
|
||||
"in - never leave conflict markers in the tree - then call " +
|
||||
$"handoff_list_handler(taskId: \"{handlerTaskId}\", survivingTaskIds: <every task not yet Done>, " +
|
||||
$"nextPhase: \"{NextPhaseFor(phase)}\") right now. Do not start anything new. " +
|
||||
"The next session opens automatically after the limit resets.";
|
||||
|
||||
// The session should resume where it is, so the warning names its own phase back. The opening
|
||||
// triage round is the exception: "triage" is not a handoff phase, and what follows triage is
|
||||
// always the wait phase.
|
||||
private static string NextPhaseFor(string phase) =>
|
||||
MergeHelperPhase.All.Contains(phase, StringComparer.Ordinal) ? phase : MergeHelperPhase.Wait;
|
||||
|
||||
/// <summary>Both PowerUse switches, exactly as <c>ComposeHandlerPromptAsync</c> and
|
||||
/// <see cref="HandlerRestartCoordinator"/> gate them — the global toggle alone must not make an
|
||||
/// unrelated list's handler session get typed into. Null = leave this round alone.</summary>
|
||||
private async Task<Policy?> EvaluateAsync(string handlerTaskId, CancellationToken ct)
|
||||
{
|
||||
if (_usageState.Snapshot?.FiveHour is not { } fiveHour) return null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(handlerTaskId, ct);
|
||||
if (task is null) return null;
|
||||
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||
if (!settings.PowerUseEnabled) return null;
|
||||
if ((await new ListRepository(ctx).GetConfigAsync(task.ListId, ct))?.PowerUseAllowed != true) return null;
|
||||
|
||||
return new Policy(Math.Clamp(settings.PowerUseHandoffPct, 50, 99), fiveHour, task.Number);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HandlerUsageGuard: could not evaluate handler task {TaskId}", handlerTaskId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ public sealed class InteractiveLaunchSpecService
|
||||
private readonly GitService _git;
|
||||
private readonly string _claudePath;
|
||||
private readonly HandoffRoundTracker _handoffRounds;
|
||||
private readonly HandlerRoundState _roundState;
|
||||
|
||||
// Local wall clock, injectable so the PowerUse time window is testable without sleeping.
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
public InteractiveLaunchSpecService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
@@ -52,8 +56,11 @@ public sealed class InteractiveLaunchSpecService
|
||||
WorktreeManager wtManager,
|
||||
GitService git,
|
||||
WorkerConfig cfg,
|
||||
HandoffRoundTracker handoffRounds)
|
||||
HandoffRoundTracker handoffRounds,
|
||||
HandlerRoundState roundState,
|
||||
Func<DateTime>? now = null)
|
||||
{
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
_dbFactory = dbFactory;
|
||||
_skillSeeder = skillSeeder;
|
||||
_skillRegistry = skillRegistry;
|
||||
@@ -61,13 +68,15 @@ public sealed class InteractiveLaunchSpecService
|
||||
_git = git;
|
||||
_claudePath = cfg.ClaudeBin;
|
||||
_handoffRounds = handoffRounds;
|
||||
_roundState = roundState;
|
||||
}
|
||||
|
||||
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
|
||||
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
|
||||
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
|
||||
/// demand (same mechanism as an autonomous run) provided the task's list has a working
|
||||
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes
|
||||
/// directory pointing at a git repo; a non-repo directory is opened directly, and a list with
|
||||
/// no working directory at all (manual/reminder list) opens in the user's home dir. Resumes
|
||||
/// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if
|
||||
/// it has one, else the latest autonomous run's session; a task that has never run either
|
||||
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
||||
@@ -86,6 +95,7 @@ public sealed class InteractiveLaunchSpecService
|
||||
var isFreshWorktree = false;
|
||||
string sessionDir;
|
||||
bool sessionIsWorktree;
|
||||
var seedSkills = true;
|
||||
|
||||
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
||||
{
|
||||
@@ -97,10 +107,17 @@ public sealed class InteractiveLaunchSpecService
|
||||
// existing shape in the codebase (a list-handler task also has no worktree and commits
|
||||
// straight to the list's working dir), so no WorktreeEntity is created here either.
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct);
|
||||
if (list?.WorkingDir is null)
|
||||
throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree.");
|
||||
|
||||
if (await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||
if (list is null || string.IsNullOrEmpty(list.WorkingDir))
|
||||
{
|
||||
// A manual/reminder list has no repo at all -- open the session in the user's
|
||||
// home directory instead of refusing it. Skills are deliberately NOT seeded
|
||||
// there: the destination would be the user's REAL ~/.claude/skills, not a
|
||||
// throwaway session copy.
|
||||
sessionDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
sessionIsWorktree = false;
|
||||
seedSkills = false;
|
||||
}
|
||||
else if (await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||
{
|
||||
// A stale (Merged/Discarded) row is already tracked in `ctx`'s identity map from
|
||||
// the read above -- CreateAsync replaces it via its OWN context, so re-querying on
|
||||
@@ -138,9 +155,12 @@ public sealed class InteractiveLaunchSpecService
|
||||
// history the task accumulated before its previous worktree went away.
|
||||
var run = isFreshWorktree ? null : await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct);
|
||||
|
||||
if (seedSkills)
|
||||
{
|
||||
var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills);
|
||||
var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct);
|
||||
await _skillSeeder.SeedAsync(sessionDir, skillNames, isWorktree: sessionIsWorktree, ct);
|
||||
}
|
||||
|
||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||
@@ -342,9 +362,20 @@ public sealed class InteractiveLaunchSpecService
|
||||
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||
};
|
||||
|
||||
// The opening round. Recorded so anything watching a handler run knows which round is live
|
||||
// (the deferred-handoff follow-up needs that), but marked "triage" rather than a
|
||||
// MergeHelperPhase value on purpose: there is no handoff spec for this phase, so
|
||||
// HandlerRestartCoordinator will not reopen it.
|
||||
_roundState.Save(new HandlerRound(handlerTaskId, TriagePhase, taskIds, DateTime.UtcNow));
|
||||
|
||||
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||
}
|
||||
|
||||
/// <summary>The <see cref="HandlerRound.Phase"/> marker for the initial (dedupe/enhance) handler
|
||||
/// session. Deliberately not a <see cref="MergeHelperPhase"/> value — that set is exactly the
|
||||
/// phases <see cref="BuildForMergeHelperHandoffAsync"/> can rebuild.</summary>
|
||||
public const string TriagePhase = "triage";
|
||||
|
||||
// Builds the LaunchSpec for the fresh session a merge-helper run hands off to for the given
|
||||
// phase -- SAME handler task id as the run that called handoff_list_handler, so
|
||||
// HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates
|
||||
@@ -449,6 +480,11 @@ public sealed class InteractiveLaunchSpecService
|
||||
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||
};
|
||||
|
||||
// Persist the round that is about to start. Phase + task set only exist in the transient
|
||||
// HandoffRequested broadcast otherwise, so without this a dead tile cannot be reopened at
|
||||
// the same phase with the same set (see HandlerRestartCoordinator).
|
||||
_roundState.Save(new HandlerRound(taskId, nextPhase, survivingTaskIds, DateTime.UtcNow));
|
||||
|
||||
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||
}
|
||||
|
||||
@@ -571,8 +607,8 @@ public sealed class InteractiveLaunchSpecService
|
||||
|
||||
// The list-handler system prompt, plus the PowerUse addendum when this run is in PowerUse.
|
||||
//
|
||||
// PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, toggled on the
|
||||
// usage monitor) AND the list's own opt-in (ListConfigEntity.PowerUseAllowed). That is the whole
|
||||
// PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, Settings →
|
||||
// Execution) AND the list's own opt-in (ListConfigEntity.PowerUseAllowed). That is the whole
|
||||
// point of the two-key design: the global switch can be left on overnight without turning some
|
||||
// unrelated list's handler run unattended, because a list that never opted in keeps the
|
||||
// interactive prompt unchanged.
|
||||
@@ -580,7 +616,11 @@ public sealed class InteractiveLaunchSpecService
|
||||
// Appending rather than substituting a second prompt set keeps the shared 95% in one place --
|
||||
// the addendum is written to read as an override of the base prompt's interactive rules. A run
|
||||
// that is not in PowerUse gets a byte-identical prompt to what it got before PowerUse existed.
|
||||
private static async Task<string> ComposeHandlerPromptAsync(
|
||||
//
|
||||
// The optional time window is checked HERE, i.e. per phase at prompt-build time: a chain that
|
||||
// hands off out of the window drops the addendum from that point on instead of staying
|
||||
// unattended because the first phase happened to start inside it.
|
||||
private async Task<string> ComposeHandlerPromptAsync(
|
||||
ClaudeDoDbContext ctx, string listId, PromptKind kind, CancellationToken ct)
|
||||
{
|
||||
var basePrompt = PromptFiles.ReadOrDefault(kind);
|
||||
@@ -588,6 +628,9 @@ public sealed class InteractiveLaunchSpecService
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||
if (!settings.PowerUseEnabled) return basePrompt;
|
||||
|
||||
if (!PowerUseWindow.IsOpen(_now(), settings.PowerUseWindowStart, settings.PowerUseWindowEnd))
|
||||
return basePrompt;
|
||||
|
||||
var listConfig = await new ListRepository(ctx).GetConfigAsync(listId, ct);
|
||||
if (listConfig?.PowerUseAllowed != true) return basePrompt;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -95,4 +95,56 @@ public sealed class PowerUseTests : IDisposable
|
||||
Assert.True(settings.PowerUseEnabled);
|
||||
Assert.Equal(40, settings.ListHandlerMaxHandoffs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppSettings_WindowDefaults_AreEmptyAnd95Percent()
|
||||
{
|
||||
var settings = await new AppSettingsRepository(_ctx).GetAsync();
|
||||
|
||||
Assert.Null(settings.PowerUseWindowStart);
|
||||
Assert.Null(settings.PowerUseWindowEnd);
|
||||
Assert.Equal(95, settings.PowerUseHandoffPct);
|
||||
}
|
||||
|
||||
/// UpdateAsync copies the incoming entity field by field onto the singleton row — a field
|
||||
/// missing from that block never persists, however correctly the hub filled the entity. That
|
||||
/// was true for PowerUseEnabled, ListHandlerMaxHandoffs and ModelPresets until 2026-09-02, so
|
||||
/// pin the whole PowerUse group through the real write path.
|
||||
[Fact]
|
||||
public async Task AppSettings_UpdateAsync_PersistsThePowerUseGroup()
|
||||
{
|
||||
var repo = new AppSettingsRepository(_ctx);
|
||||
var current = await repo.GetAsync();
|
||||
current.PowerUseEnabled = true;
|
||||
current.PowerUseWindowStart = "22:00";
|
||||
current.PowerUseWindowEnd = "07:00";
|
||||
current.PowerUseHandoffPct = 88;
|
||||
current.ListHandlerMaxHandoffs = 40;
|
||||
await repo.UpdateAsync(current);
|
||||
|
||||
using var reload = Reload();
|
||||
var settings = await new AppSettingsRepository(reload).GetAsync();
|
||||
Assert.True(settings.PowerUseEnabled);
|
||||
Assert.Equal("22:00", settings.PowerUseWindowStart);
|
||||
Assert.Equal("07:00", settings.PowerUseWindowEnd);
|
||||
Assert.Equal(88, settings.PowerUseHandoffPct);
|
||||
Assert.Equal(40, settings.ListHandlerMaxHandoffs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppSettings_UpdateAsync_NormalizesAndClamps()
|
||||
{
|
||||
var repo = new AppSettingsRepository(_ctx);
|
||||
var current = await repo.GetAsync();
|
||||
current.PowerUseWindowStart = "nonsense";
|
||||
current.PowerUseWindowEnd = " 07:00 ";
|
||||
current.PowerUseHandoffPct = 5;
|
||||
await repo.UpdateAsync(current);
|
||||
|
||||
using var reload = Reload();
|
||||
var settings = await new AppSettingsRepository(reload).GetAsync();
|
||||
Assert.Null(settings.PowerUseWindowStart);
|
||||
Assert.Equal("07:00", settings.PowerUseWindowEnd);
|
||||
Assert.Equal(50, settings.PowerUseHandoffPct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
/// The window decides whether a handler session runs unattended, so its edges matter: an
|
||||
/// off-by-one at midnight would leave an overnight window either always or never open.
|
||||
public sealed class PowerUseWindowTests
|
||||
{
|
||||
private static TimeOnly T(string hhmm) => TimeOnly.ParseExact(hhmm, "HH:mm");
|
||||
|
||||
[Theory]
|
||||
// Plain window, half-open: start included, end excluded.
|
||||
[InlineData("09:00", "08:00", "17:00", true)]
|
||||
[InlineData("08:00", "08:00", "17:00", true)]
|
||||
[InlineData("17:00", "08:00", "17:00", false)]
|
||||
[InlineData("07:59", "08:00", "17:00", false)]
|
||||
[InlineData("23:30", "08:00", "17:00", false)]
|
||||
// Wrap over midnight.
|
||||
[InlineData("23:30", "22:00", "07:00", true)]
|
||||
[InlineData("00:30", "22:00", "07:00", true)]
|
||||
[InlineData("12:00", "22:00", "07:00", false)]
|
||||
[InlineData("07:00", "22:00", "07:00", false)]
|
||||
public void Contains_BothBoundsSet(string now, string start, string end, bool expected)
|
||||
=> Assert.Equal(expected, PowerUseWindow.Contains(T(now), T(start), T(end)));
|
||||
|
||||
/// Only one bound is not a window — it must behave exactly like no window, or a half-filled
|
||||
/// pair of boxes would silently switch PowerUse off for most of the day.
|
||||
[Fact]
|
||||
public void Contains_OnlyOneBoundSet_IsAlwaysOpen()
|
||||
{
|
||||
Assert.True(PowerUseWindow.Contains(T("12:00"), T("22:00"), null));
|
||||
Assert.True(PowerUseWindow.Contains(T("12:00"), null, T("07:00")));
|
||||
Assert.True(PowerUseWindow.Contains(T("12:00"), null, null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("22:00", "22:00")]
|
||||
[InlineData(" 07:30 ", "07:30")]
|
||||
[InlineData("7:30", null)]
|
||||
[InlineData("22:00:00", null)]
|
||||
[InlineData("nonsense", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void Normalize_KeepsOnlyExactHhMm(string? input, string? expected)
|
||||
=> Assert.Equal(expected, PowerUseWindow.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_UsesTheTimeOfDayOfTheGivenInstant()
|
||||
{
|
||||
Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 23, 30, 0), "22:00", "07:00"));
|
||||
Assert.False(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), "22:00", "07:00"));
|
||||
Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), null, null));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||
public event Action? NotesUpdatedEvent;
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
@@ -56,7 +58,9 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public void RaiseListUpdated(string listId) => ListUpdatedEvent?.Invoke(listId);
|
||||
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
||||
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
||||
public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
|
||||
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
||||
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
|
||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||
@@ -132,6 +136,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
|
||||
=> 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,34 @@
|
||||
using System.Linq;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class CheatSheetsViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadsEverySheetWithTitleFromHeading()
|
||||
{
|
||||
var vm = new CheatSheetsViewModel();
|
||||
|
||||
Assert.Equal(6, vm.Sheets.Count);
|
||||
Assert.Equal(
|
||||
["Git", "Conventional Commits", "Claude CLI", "Claude Konfiguration", "dotnet CLI", "PowerShell"],
|
||||
vm.Sheets.Select(s => s.Title));
|
||||
// The heading is stripped so the window doesn't render the title twice.
|
||||
Assert.All(vm.Sheets, s => Assert.StartsWith("## ", s.Body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectSwitchesSheetAndIgnoresUnknownKeys()
|
||||
{
|
||||
var vm = new CheatSheetsViewModel();
|
||||
Assert.Equal("git", vm.SelectedSheet!.Key);
|
||||
|
||||
vm.Select("dotnet-cli");
|
||||
Assert.Equal("dotnet-cli", vm.SelectedSheet!.Key);
|
||||
|
||||
vm.Select("nope");
|
||||
Assert.Equal("dotnet-cli", vm.SelectedSheet!.Key);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ public class ListsIslandCreateListCancelTests : IDisposable
|
||||
public Task<bool> ConfirmAsync(string message) => Task.FromResult(true);
|
||||
public Task ShowErrorAsync(string message) => Task.CompletedTask;
|
||||
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class ListSettingsProvider(Func<ListSettingsModalViewModel> create) : IServiceProvider
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Xunit;
|
||||
|
||||
@@ -150,5 +151,62 @@ public class ConPtyPaneViewModelTests
|
||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SessionDiedAfterStartup_TaskPane_RaisesWithTaskIdAndExitCode()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes, taskId: "handler-1");
|
||||
(string TaskId, int ExitCode)? reported = null;
|
||||
pane.SessionDiedAfterStartup += (id, code) => reported = (id, code);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.Equal(("handler-1", 1), reported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
|
||||
{
|
||||
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
|
||||
var raised = false;
|
||||
pane.SessionDiedAfterStartup += (_, _) => raised = true;
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.False(raised);
|
||||
}
|
||||
|
||||
// Retry swaps in a fresh terminal -- the death of the RETRIED session must still be reported.
|
||||
[Fact]
|
||||
public void SessionDiedAfterStartup_StillWiredAfterRetry()
|
||||
{
|
||||
var callCount = 0;
|
||||
using var pane = NewTaskPane(
|
||||
() => callCount++ == 0 ? Failing() : NeverCompletes(), taskId: "handler-1");
|
||||
pane.Start();
|
||||
pane.RetryCommand.Execute(null);
|
||||
|
||||
var raised = false;
|
||||
pane.SessionDiedAfterStartup += (_, _) => raised = true;
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
|
||||
|
||||
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
|
||||
// time off the real clock, so a sleep-free "ran for a while" needs the exit routed through a
|
||||
// terminal whose clock has already moved on.
|
||||
private static void DieAfterRunning(ConPtyPaneViewModel pane, int exitCode)
|
||||
{
|
||||
var launchedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var callCount = 0;
|
||||
pane.Terminal = new InteractiveTerminalViewModel(
|
||||
() => callCount++ == 0 ? launchedAt : launchedAt.AddMinutes(90));
|
||||
pane.Terminal.IsRunning = true;
|
||||
pane.Terminal.OnSessionProcessExited(null, exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -49,4 +49,54 @@ public class InteractiveTerminalViewModelTests
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
}
|
||||
|
||||
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
|
||||
// the worker turns it into a usage-limit auto-restart for list handlers) ───────────────────
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_AfterRunningAWhile_RaisesExitedAfterStartup()
|
||||
{
|
||||
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
|
||||
int? reported = null;
|
||||
terminal.ExitedAfterStartup += code => reported = code;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1);
|
||||
|
||||
Assert.Equal(1, reported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_WithinGraceWindow_DoesNotRaiseExitedAfterStartup()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
terminal.IsRunning = true;
|
||||
var raised = false;
|
||||
terminal.ExitedAfterStartup += _ => raised = true;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1);
|
||||
|
||||
Assert.False(raised);
|
||||
Assert.NotNull(terminal.StartError); // still the startup-failure banner
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsZero_AfterRunningAWhile_DoesNotRaiseExitedAfterStartup()
|
||||
{
|
||||
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
|
||||
var raised = false;
|
||||
terminal.ExitedAfterStartup += _ => raised = true;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 0);
|
||||
|
||||
Assert.False(raised);
|
||||
}
|
||||
|
||||
private static InteractiveTerminalViewModel NewTerminalExitingAfter(TimeSpan uptime)
|
||||
{
|
||||
var callCount = 0;
|
||||
var terminal = new InteractiveTerminalViewModel(
|
||||
() => callCount++ == 0 ? LaunchedAt : LaunchedAt + uptime);
|
||||
terminal.IsRunning = true; // records LaunchedAt via the first clock call
|
||||
return terminal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,11 +254,11 @@ public class MissionControlViewModelTests : IDisposable
|
||||
|
||||
await vm.RefreshSessionTargetsAsync();
|
||||
|
||||
// Browse first (survives a failing list query), then the lists in sort order.
|
||||
Assert.Equal(3, vm.SessionTargets.Count);
|
||||
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(1).Select(t => t.Label).ToArray());
|
||||
// Home + browse first (they survive a failing list query), then the lists in sort order.
|
||||
Assert.Equal(4, vm.SessionTargets.Count);
|
||||
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(2).Select(t => t.Label).ToArray());
|
||||
|
||||
vm.SessionTargets[0].StartCommand.Execute(null);
|
||||
vm.SessionTargets[1].StartCommand.Execute(null);
|
||||
Assert.True(browsed);
|
||||
}
|
||||
|
||||
@@ -867,6 +867,93 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
|
||||
}
|
||||
|
||||
// ── usage-limit auto-restart: the exit code has to reach the worker ─────────────────────────
|
||||
|
||||
private sealed class RecordingExitWorker : StubWorkerClient
|
||||
{
|
||||
public readonly List<(string TaskId, int ExitCode)> Reported = new();
|
||||
public override Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||
{
|
||||
Reported.Add((taskId, exitCode));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// A handler tile that ran for a while and then died non-zero used to be a silent dead pane.
|
||||
// Whether it was the usage limit (and worth reopening) is the worker's call, so the UI's only
|
||||
// job is reporting the exit -- hence no filtering here, every task pane reports.
|
||||
[Fact]
|
||||
public async Task PaneSessionDiesAfterRunning_ReportsExitToWorker()
|
||||
{
|
||||
var worker = new RecordingExitWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.Equal(new[] { ("handler-1", 1) }, worker.Reported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosedPane_DoesNotReportItsSessionExit()
|
||||
{
|
||||
var worker = new RecordingExitWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.Empty(worker.Reported);
|
||||
}
|
||||
|
||||
// Drives a pane's terminal past its startup grace window and then kills it. IsRunning stamps the
|
||||
// launch time off the terminal's own clock, so a sleep-free "ran for 90 minutes" needs a fresh
|
||||
// terminal with a stubbed clock -- the pane re-wires its handlers on assignment.
|
||||
private static void DieAfterRunning(ConPtyPaneViewModel pane, int exitCode)
|
||||
{
|
||||
var launchedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var callCount = 0;
|
||||
pane.Terminal = new InteractiveTerminalViewModel(
|
||||
() => callCount++ == 0 ? launchedAt : launchedAt.AddMinutes(90));
|
||||
pane.Terminal.IsRunning = true;
|
||||
pane.Terminal.OnSessionProcessExited(null, exitCode);
|
||||
}
|
||||
|
||||
// The worker's pre-usage-limit warning is typed into the pane for that handler task. There is no
|
||||
// ConPTY process in a headless test, so SendLineAsync reports "not delivered" -- what this pins
|
||||
// is the routing: the right pane is picked, and a warning for a task with no pane open is a
|
||||
// silent no-op rather than an error in the footer.
|
||||
[Fact]
|
||||
public async Task HandlerUsageWarning_WithNoPaneForTheTask_IsSilentlyIgnored()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? error = null;
|
||||
vm.ErrorReported += msg => error = msg;
|
||||
|
||||
await vm.WarnHandlerSessionAsync("handler-nobody-opened", "USAGE LIMIT IMMINENT");
|
||||
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlerUsageWarning_UndeliverableToItsPane_SurfacesAnError()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
string? error = null;
|
||||
vm.ErrorReported += msg => error = msg;
|
||||
|
||||
await vm.WarnHandlerSessionAsync("handler-1", "USAGE LIMIT IMMINENT");
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleLayoutCommand_FlipsIsFocusMode()
|
||||
{
|
||||
@@ -879,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,38 @@ public class SettingsModalViewModelTests : IDisposable
|
||||
Assert.Equal(77, worker.Saved.UsageGateSevenDayPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PowerUse_survives_a_load_edit_save_round_trip()
|
||||
{
|
||||
// PowerUse moved off the usage monitor onto this tab, so Settings is now its only writer.
|
||||
// A binding that doesn't reach General silently resets the switch to the record default.
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
AppToReturn = DtoWith(80, 90) with
|
||||
{
|
||||
PowerUseEnabled = true,
|
||||
PowerUseWindowStart = "18:00",
|
||||
PowerUseWindowEnd = "06:00",
|
||||
PowerUseHandoffPct = 88,
|
||||
},
|
||||
};
|
||||
var vm = MakeVm(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.General.PowerUseEnabled);
|
||||
Assert.Equal("18:00", vm.General.PowerUseWindowStart);
|
||||
Assert.Equal(88, vm.General.PowerUseHandoffPct);
|
||||
|
||||
// A typo in the window is normalized to null on save, not stored as a fake window.
|
||||
vm.General.PowerUseWindowEnd = "not a time";
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(worker.Saved!.PowerUseEnabled);
|
||||
Assert.Equal("18:00", worker.Saved.PowerUseWindowStart);
|
||||
Assert.Null(worker.Saved.PowerUseWindowEnd);
|
||||
Assert.Equal(88, worker.Saved.PowerUseHandoffPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasLinkedRepo_false_when_no_list_has_a_working_dir()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -63,6 +63,7 @@ public class TasksIslandMoveToListTests : IDisposable
|
||||
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowErrorAsync(string message) => Task.CompletedTask;
|
||||
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using ClaudeDo.Ui.Views;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.Views;
|
||||
|
||||
public class InteractiveTerminalDropPathsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Quotes_only_paths_with_spaces_and_ends_with_a_separator()
|
||||
{
|
||||
var text = InteractiveTerminalView.FormatDroppedPaths(
|
||||
new[] { @"C:\repo\a.cs", @"C:\my repo\b.cs" });
|
||||
|
||||
Assert.Equal("C:\\repo\\a.cs \"C:\\my repo\\b.cs\" ", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skips_items_without_a_local_path()
|
||||
{
|
||||
// IStorageItem.TryGetLocalPath() returns null for non-filesystem items (e.g. a virtual
|
||||
// shell folder) — those must not turn into empty quotes in the composer.
|
||||
Assert.Equal(@"C:\repo\a.cs ", InteractiveTerminalView.FormatDroppedPaths(new[] { null, @"C:\repo\a.cs", " " }));
|
||||
Assert.Equal(string.Empty, InteractiveTerminalView.FormatDroppedPaths(new string?[] { null }));
|
||||
Assert.Equal(string.Empty, InteractiveTerminalView.FormatDroppedPaths(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ClaudeDo.Ui.Views.Controls;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the command-row split only — rendering itself needs an Avalonia app, which this
|
||||
/// project has no host for.
|
||||
/// </summary>
|
||||
public class MarkdownCommandRowTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("`git status -sb` — Kurzstatus mit Branch-Zeile", "git status -sb", "Kurzstatus mit Branch-Zeile")]
|
||||
[InlineData("`git branch -d <name>` — löschen (`-D` erzwingt)", "git branch -d <name>", "löschen (`-D` erzwingt)")]
|
||||
[InlineData("`dotnet build`", "dotnet build", "")]
|
||||
public void SplitsCommandFromDescription(string line, string cmd, string desc)
|
||||
{
|
||||
Assert.True(MarkdownView.TrySplitCommand(line, out var actualCmd, out var actualDesc));
|
||||
Assert.Equal(cmd, actualCmd);
|
||||
Assert.Equal(desc, actualDesc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Prosa ohne Befehl")]
|
||||
[InlineData("Text mit `code` in der Mitte")]
|
||||
[InlineData("`unclosed backtick")]
|
||||
[InlineData("``")]
|
||||
public void LeavesNonCommandBulletsToThePlainRenderer(string line)
|
||||
{
|
||||
Assert.False(MarkdownView.TrySplitCommand(line, out _, out _));
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
|
||||
"list_runs", "get_run", "get_task_log",
|
||||
"list_agents",
|
||||
"reset_failed_task", "submit_task_for_review",
|
||||
"handoff_list_handler", "get_handoff_status",
|
||||
"handoff_list_handler", "get_handoff_status", "start_list_handler",
|
||||
"get_app_settings",
|
||||
"wait_for_task_change",
|
||||
"get_queue_state",
|
||||
|
||||
@@ -3,8 +3,11 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
@@ -16,18 +19,35 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly CapturingHubContext _hubContext = new();
|
||||
private readonly string _tempDir;
|
||||
|
||||
public HandoffMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_handoffmcp_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
try { Directory.Delete(_tempDir, true); } catch { }
|
||||
}
|
||||
|
||||
private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null) =>
|
||||
new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker(), _db.CreateFactory());
|
||||
// No usage snapshot -> HandlerUsageGuard never withholds, which is the behaviour every test
|
||||
// below assumes. The withholding path has its own tests in HandlerUsageGuardTests.
|
||||
private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null)
|
||||
{
|
||||
var rounds = tracker ?? new HandoffRoundTracker();
|
||||
var guard = new HandlerUsageGuard(
|
||||
_db.CreateFactory(), new UsageState(),
|
||||
new HandlerRoundState(Path.Combine(_tempDir, "handler-rounds.json")), rounds,
|
||||
new HubBroadcaster(_hubContext), NullLogger<HandlerUsageGuard>.Instance);
|
||||
return new HandoffMcpTools(_tasks, new HubBroadcaster(_hubContext), rounds, _db.CreateFactory(), guard);
|
||||
}
|
||||
|
||||
/// The cap the SUT reads comes from app_settings, so a test that wants a different cap has to
|
||||
/// write it there. GetAsync is AsNoTracking, hence the separate tracked query.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,9 @@ public sealed class MergeHelperTaskHubTests : IDisposable
|
||||
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
|
||||
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
|
||||
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
|
||||
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker());
|
||||
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker(),
|
||||
new ClaudeDo.Worker.Runner.HandlerRoundState(
|
||||
Path.Combine(Path.GetTempPath(), $"claudedo_rounds_{Guid.NewGuid():N}.json")));
|
||||
var built = TaskStateServiceBuilder.Build(factory);
|
||||
var reviewSubmission = new InteractiveReviewSubmissionService(factory, git, built.State, wtManager);
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
/// Settings rebuilds the whole AppSettings DTO positionally on save, so the contract that matters
|
||||
/// is: whatever GetAppSettings hands out, feeding it straight back must change nothing.
|
||||
public sealed class PowerUseSettingsHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppSettings_Defaults_HaveNoWindowAnd95Percent()
|
||||
{
|
||||
var settings = await CreateHub().GetAppSettings();
|
||||
|
||||
Assert.Null(settings.PowerUseWindowStart);
|
||||
Assert.Null(settings.PowerUseWindowEnd);
|
||||
Assert.Equal(95, settings.PowerUseHandoffPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppSettings_RoundTripsTheWindowAndThreshold()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
|
||||
var before = await hub.GetAppSettings();
|
||||
await hub.UpdateAppSettings(before with
|
||||
{
|
||||
PowerUseEnabled = true,
|
||||
PowerUseWindowStart = "22:00",
|
||||
PowerUseWindowEnd = "07:00",
|
||||
PowerUseHandoffPct = 88,
|
||||
});
|
||||
var after = await hub.GetAppSettings();
|
||||
|
||||
Assert.True(after.PowerUseEnabled);
|
||||
Assert.Equal("22:00", after.PowerUseWindowStart);
|
||||
Assert.Equal("07:00", after.PowerUseWindowEnd);
|
||||
Assert.Equal(88, after.PowerUseHandoffPct);
|
||||
}
|
||||
|
||||
/// The Settings→General case: a save that touches none of the PowerUse fields must leave them
|
||||
/// exactly as they were.
|
||||
[Fact]
|
||||
public async Task UpdateAppSettings_UnchangedDto_LeavesPowerUseFieldsAlone()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
await hub.UpdateAppSettings((await hub.GetAppSettings()) with
|
||||
{
|
||||
PowerUseEnabled = true,
|
||||
PowerUseWindowStart = "22:00",
|
||||
PowerUseWindowEnd = "07:00",
|
||||
PowerUseHandoffPct = 88,
|
||||
});
|
||||
|
||||
var stored = await hub.GetAppSettings();
|
||||
await hub.UpdateAppSettings(stored);
|
||||
var after = await hub.GetAppSettings();
|
||||
|
||||
Assert.Equal(stored.PowerUseEnabled, after.PowerUseEnabled);
|
||||
Assert.Equal(stored.PowerUseWindowStart, after.PowerUseWindowStart);
|
||||
Assert.Equal(stored.PowerUseWindowEnd, after.PowerUseWindowEnd);
|
||||
Assert.Equal(stored.PowerUseHandoffPct, after.PowerUseHandoffPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppSettings_InvalidTimeAndOutOfRangePct_AreNormalized()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
|
||||
await hub.UpdateAppSettings((await hub.GetAppSettings()) with
|
||||
{
|
||||
PowerUseWindowStart = "25:99",
|
||||
PowerUseWindowEnd = "07:00",
|
||||
PowerUseHandoffPct = 120,
|
||||
});
|
||||
var after = await hub.GetAppSettings();
|
||||
|
||||
Assert.Null(after.PowerUseWindowStart);
|
||||
Assert.Equal("07:00", after.PowerUseWindowEnd);
|
||||
Assert.Equal(99, after.PowerUseHandoffPct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Runner;
|
||||
|
||||
/// Auto-restart of a list-handler ConPTY session that died at the usage limit. The UI half (the
|
||||
/// exit code reaching the worker at all) lives in Ui.Tests' InteractiveTerminalViewModel /
|
||||
/// MissionControlViewModel tests.
|
||||
public sealed class HandlerRestartCoordinatorTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly string _tempDir;
|
||||
private readonly CapturingHubContext _hub = new();
|
||||
private readonly HandlerRoundState _rounds;
|
||||
|
||||
private static readonly DateTimeOffset ResetsAt = new(2026, 9, 3, 2, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public HandlerRestartCoordinatorTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_handlerrestart_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_rounds = new HandlerRoundState(Path.Combine(_tempDir, "handler-rounds.json"));
|
||||
}
|
||||
|
||||
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
|
||||
|
||||
private static readonly DateTimeOffset DueAt = ResetsAt + HandlerUsageGuard.ResetGrace;
|
||||
|
||||
private HandlerRestartCoordinator BuildCoordinator(UsageState usage) =>
|
||||
new(_db.CreateFactory(), usage, _rounds, new HubBroadcaster(_hub),
|
||||
NullLogger<HandlerRestartCoordinator>.Instance);
|
||||
|
||||
/// The coordinator only parks the restart; this is what actually fires it. `now` decides whether
|
||||
/// the parked handoff is already due, so a 5h wait costs a test nothing.
|
||||
private HandlerUsageGuard BuildGuard(UsageState usage, DateTimeOffset now) =>
|
||||
new(_db.CreateFactory(), usage, _rounds, new HandoffRoundTracker(), new HubBroadcaster(_hub),
|
||||
NullLogger<HandlerUsageGuard>.Instance)
|
||||
{ NowUtc = () => now };
|
||||
|
||||
/// The one signal a ConPTY death gives the worker: the 5h window pinned at the limit.
|
||||
private static UsageState AtUsageLimit(double percent = 100) =>
|
||||
Snapshot(new UsageBucket(percent, ResetsAt));
|
||||
|
||||
private static UsageState Snapshot(UsageBucket? fiveHour)
|
||||
{
|
||||
var state = new UsageState();
|
||||
state.ReportSuccess(new UsageSnapshot(fiveHour, null, Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
||||
return state;
|
||||
}
|
||||
|
||||
private int _nextNumber = 1;
|
||||
|
||||
private async Task<string> SeedHandlerTaskAsync(bool powerUseEnabled = true, bool powerUseAllowed = true)
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = _tempDir, CreatedAt = DateTime.UtcNow });
|
||||
ctx.ListConfigs.Add(new ListConfigEntity { ListId = listId, PowerUseAllowed = powerUseAllowed });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "Handler", Number = _nextNumber++,
|
||||
IsManual = true, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
// Written straight to the row so the fixture doesn't depend on the repository's
|
||||
// clamping/normalizing copy block.
|
||||
await new AppSettingsRepository(ctx).GetAsync(); // materializes the singleton row
|
||||
var settings = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
settings.PowerUseEnabled = powerUseEnabled;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private IReadOnlyList<CapturedHubCall> HandoffCalls =>
|
||||
_hub.Proxy.Calls.Where(c => c.Method == "HandoffRequested").ToList();
|
||||
|
||||
// ── acceptance 1: same phase, same task set, after the reset ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UsageLimitDeath_ReopensSamePhaseWithSameTaskSet_AfterTheReset()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a", "b" }, DateTime.UtcNow));
|
||||
|
||||
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
|
||||
|
||||
// Parked, not relaunched straight into the same exhausted window.
|
||||
Assert.Empty(HandoffCalls);
|
||||
Assert.Equal(DueAt, _rounds.Get(taskId)!.DeferredHandoffAt);
|
||||
|
||||
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
|
||||
|
||||
var call = Assert.Single(HandoffCalls);
|
||||
Assert.Equal(taskId, call.Args[0]);
|
||||
Assert.Equal(new[] { "a", "b" }, (IReadOnlyList<string>)call.Args[1]!);
|
||||
Assert.Equal(MergeHelperPhase.Merge, call.Args[2]);
|
||||
}
|
||||
|
||||
// The #272 review finding: the restart budget used to be spent when the wait STARTED, so a
|
||||
// worker that died during the hours-long wait left the round marked "restart used" without one
|
||||
// ever having happened -- and the session stayed dead forever.
|
||||
[Fact]
|
||||
public async Task ParkedRestart_SurvivesAWorkerRestart_AndOnlySpendsTheBudgetOnceItFires()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
|
||||
|
||||
// Worker restart: a fresh state object re-reads the same file, nothing else carries over.
|
||||
var afterRestart = new HandlerRoundState(Path.Combine(_tempDir, "handler-rounds.json"));
|
||||
Assert.False(afterRestart.Get(taskId)!.AutoRestartUsed);
|
||||
Assert.Equal(DueAt, afterRestart.Get(taskId)!.DeferredHandoffAt);
|
||||
|
||||
// Still parked while the window is open...
|
||||
await BuildGuard(AtUsageLimit(), DueAt.AddMinutes(-5)).TickAsync(default);
|
||||
Assert.Empty(HandoffCalls);
|
||||
|
||||
// ...and sent as soon as it is due, which is also when the budget is spent.
|
||||
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
|
||||
Assert.Single(HandoffCalls);
|
||||
Assert.True(_rounds.Get(taskId)!.AutoRestartUsed);
|
||||
Assert.Null(_rounds.Get(taskId)!.DeferredHandoffAt);
|
||||
}
|
||||
|
||||
// ── acceptance 2: at most one auto-restart per round ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SecondDeathInTheSameRound_DoesNotRestartAgain()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var coordinator = BuildCoordinator(AtUsageLimit());
|
||||
|
||||
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
|
||||
|
||||
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
Assert.Single(HandoffCalls);
|
||||
}
|
||||
|
||||
// A second death while the first restart is still parked must not overwrite or duplicate it.
|
||||
[Fact]
|
||||
public async Task SecondDeathWhileTheRestartIsStillParked_ChangesNothing()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var coordinator = BuildCoordinator(AtUsageLimit());
|
||||
|
||||
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
|
||||
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
|
||||
Assert.Single(HandoffCalls);
|
||||
}
|
||||
|
||||
// The restart relaunches the SAME phase, which writes the round again through
|
||||
// InteractiveLaunchSpecService -- that re-save must not hand the round a fresh restart budget.
|
||||
[Fact]
|
||||
public async Task RestartRebuildingTheSamePhase_DoesNotRefreshTheRestartBudget()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var coordinator = BuildCoordinator(AtUsageLimit());
|
||||
|
||||
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
|
||||
// The relaunched session's launch spec writes the round again, same phase.
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
Assert.Single(HandoffCalls);
|
||||
}
|
||||
|
||||
// ── acceptance 3: PowerUse gates it ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListWithoutPowerUseOptIn_NoRestart()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync(powerUseAllowed: false);
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var coordinator = BuildCoordinator(AtUsageLimit());
|
||||
|
||||
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
|
||||
Assert.Empty(HandoffCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PowerUseGloballyOff_NoRestart()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync(powerUseEnabled: false);
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var coordinator = BuildCoordinator(AtUsageLimit());
|
||||
|
||||
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
|
||||
Assert.Empty(HandoffCalls);
|
||||
}
|
||||
|
||||
// ── acceptance 4: a restart spends no handoff budget ───────────────────────────────────────
|
||||
|
||||
// handoff_list_handler is the only caller of HandoffRoundTracker.Register, and the restart never
|
||||
// goes through it -- so a run interrupted by the usage limit does not lose a round of its
|
||||
// wait/merge chain. Asserted against the tool the handler session itself reads the budget from.
|
||||
[Fact]
|
||||
public async Task Restart_DoesNotConsumeHandoffBudget()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
var tracker = new HandoffRoundTracker();
|
||||
tracker.Register(taskId, MergeHelperPhase.Merge, 4); // the run's real handoff into this phase
|
||||
|
||||
using var ctx = _db.CreateContext();
|
||||
var usage = AtUsageLimit();
|
||||
var tools = new HandoffMcpTools(
|
||||
new TaskRepository(ctx), new HubBroadcaster(_hub), tracker, _db.CreateFactory(),
|
||||
BuildGuard(usage, ResetsAt));
|
||||
var before = await tools.GetHandoffStatus(taskId);
|
||||
|
||||
Assert.True(await BuildCoordinator(usage).OnSessionExitedAsync(taskId, 1));
|
||||
await BuildGuard(usage, DueAt).TickAsync(default);
|
||||
|
||||
var after = await tools.GetHandoffStatus(taskId);
|
||||
Assert.Equal(before.Round, after.Round);
|
||||
Assert.Equal(before.Remaining, after.Remaining);
|
||||
Assert.False(after.Final);
|
||||
}
|
||||
|
||||
// ── proof requirement: a non-zero exit alone is not enough ─────────────────────────────────
|
||||
|
||||
// The user closed the session -- exit 0 even sitting at the usage limit.
|
||||
[Fact]
|
||||
public async Task CleanExit_NoRestart()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
Assert.False(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 0));
|
||||
Assert.Empty(HandoffCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonZeroExit_WellBelowTheLimit_IsTreatedAsACrash_NoRestart()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
Assert.False(await BuildCoordinator(AtUsageLimit(percent: 40)).OnSessionExitedAsync(taskId, 1));
|
||||
Assert.Empty(HandoffCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoUsageSnapshot_FailsClosed_NoRestart()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
Assert.False(await BuildCoordinator(new UsageState()).OnSessionExitedAsync(taskId, 1));
|
||||
Assert.Empty(HandoffCalls);
|
||||
}
|
||||
|
||||
// ── not a handler round at all ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PlainInteractiveSession_HasNoRound_NoRestart()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
|
||||
Assert.False(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
|
||||
Assert.Empty(HandoffCalls);
|
||||
}
|
||||
|
||||
// The triage round has no handoff launch spec, so there is nothing to reopen it with.
|
||||
[Fact]
|
||||
public async Task TriageRound_IsNotReopened()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(
|
||||
taskId, InteractiveLaunchSpecService.TriagePhase, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
Assert.False(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
|
||||
Assert.Empty(HandoffCalls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Runner;
|
||||
|
||||
/// Handing a PowerUse list-handler round over cleanly across the 5h usage limit: warn the live
|
||||
/// session once, then withhold the handoff it produces until the window resets. The UI half (the
|
||||
/// warning actually reaching the ConPTY session) lives in Ui.Tests' MissionControlViewModel tests.
|
||||
public sealed class HandlerUsageGuardTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly string _tempDir;
|
||||
private readonly CapturingHubContext _hub = new();
|
||||
private readonly HandlerRoundState _rounds;
|
||||
private readonly HandoffRoundTracker _handoffRounds = new();
|
||||
|
||||
private static readonly DateTimeOffset ResetsAt = new(2026, 9, 3, 2, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset DueAt = ResetsAt + HandlerUsageGuard.ResetGrace;
|
||||
private static readonly DateTimeOffset Now = ResetsAt.AddHours(-3);
|
||||
|
||||
public HandlerUsageGuardTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_usageguard_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_rounds = new HandlerRoundState(RoundsPath);
|
||||
}
|
||||
|
||||
private string RoundsPath => Path.Combine(_tempDir, "handler-rounds.json");
|
||||
|
||||
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
|
||||
|
||||
private static UsageState Usage(double percent, DateTimeOffset? resetsAt) =>
|
||||
Snapshot(new UsageBucket(percent, resetsAt));
|
||||
|
||||
private static UsageState Snapshot(UsageBucket? fiveHour)
|
||||
{
|
||||
var state = new UsageState();
|
||||
state.ReportSuccess(new UsageSnapshot(fiveHour, null, Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
||||
return state;
|
||||
}
|
||||
|
||||
private HandlerUsageGuard BuildGuard(UsageState usage, DateTimeOffset? now = null, HandlerRoundState? rounds = null) =>
|
||||
new(_db.CreateFactory(), usage, rounds ?? _rounds, _handoffRounds, new HubBroadcaster(_hub),
|
||||
NullLogger<HandlerUsageGuard>.Instance)
|
||||
{ NowUtc = () => now ?? Now };
|
||||
|
||||
private int _nextNumber = 1;
|
||||
|
||||
private async Task<string> SeedHandlerTaskAsync(bool powerUseEnabled = true, bool powerUseAllowed = true)
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = _tempDir, CreatedAt = DateTime.UtcNow });
|
||||
ctx.ListConfigs.Add(new ListConfigEntity { ListId = listId, PowerUseAllowed = powerUseAllowed });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "Handler", Number = _nextNumber++,
|
||||
IsManual = true, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
// Written straight to the row so the fixture doesn't depend on the repository's
|
||||
// clamping/normalizing copy block.
|
||||
await new AppSettingsRepository(ctx).GetAsync();
|
||||
var settings = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
settings.PowerUseEnabled = powerUseEnabled;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private IReadOnlyList<CapturedHubCall> Calls(string method) =>
|
||||
_hub.Proxy.Calls.Where(c => c.Method == method).ToList();
|
||||
|
||||
private IReadOnlyList<CapturedHubCall> Warnings => Calls("HandlerUsageWarning");
|
||||
private IReadOnlyList<CapturedHubCall> HandoffCalls => Calls("HandoffRequested");
|
||||
|
||||
// ── acceptance 1: exactly one warning per round ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AtTheThreshold_WarnsTheSessionExactlyOncePerRound()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var guard = BuildGuard(Usage(95, ResetsAt));
|
||||
|
||||
await guard.TickAsync(default);
|
||||
await guard.TickAsync(default);
|
||||
await BuildGuard(Usage(99, ResetsAt)).TickAsync(default);
|
||||
|
||||
var warning = Assert.Single(Warnings);
|
||||
Assert.Equal(taskId, warning.Args[0]);
|
||||
var message = (string)warning.Args[1]!;
|
||||
Assert.Contains("USAGE LIMIT IMMINENT", message);
|
||||
// Names the session's own phase back, so it resumes where it is.
|
||||
Assert.Contains($"nextPhase: \"{MergeHelperPhase.Merge}\"", message);
|
||||
Assert.Contains(taskId, message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BelowTheThreshold_NoWarning()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
await BuildGuard(Usage(94, ResetsAt)).TickAsync(default);
|
||||
|
||||
Assert.Empty(Warnings);
|
||||
}
|
||||
|
||||
// The triage round has no "triage" handoff phase -- what follows it is always the wait phase.
|
||||
[Fact]
|
||||
public async Task TriageRound_IsToldToHandOffToTheWaitPhase()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(
|
||||
taskId, InteractiveLaunchSpecService.TriagePhase, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
await BuildGuard(Usage(96, ResetsAt)).TickAsync(default);
|
||||
|
||||
Assert.Contains($"nextPhase: \"{MergeHelperPhase.Wait}\"", (string)Assert.Single(Warnings).Args[1]!);
|
||||
}
|
||||
|
||||
// A sealed chain can't hand off at all -- telling it to would only earn the session an exception.
|
||||
[Fact]
|
||||
public async Task SealedChain_IsNotWarned()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.MergeFinal, new[] { "a" }, DateTime.UtcNow));
|
||||
_handoffRounds.Register(taskId, MergeHelperPhase.MergeFinal);
|
||||
|
||||
await BuildGuard(Usage(99, ResetsAt)).TickAsync(default);
|
||||
|
||||
Assert.Empty(Warnings);
|
||||
}
|
||||
|
||||
// ── acceptance 5: PowerUse gates both halves ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
public async Task PowerUseOff_NeitherWarnsNorWithholds(bool powerUseEnabled, bool powerUseAllowed)
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync(powerUseEnabled, powerUseAllowed);
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var guard = BuildGuard(Usage(99, ResetsAt));
|
||||
|
||||
await guard.TickAsync(default);
|
||||
|
||||
Assert.Empty(Warnings);
|
||||
Assert.Null(await guard.TryDeferHandoffAsync(taskId, new[] { "a" }, MergeHelperPhase.Merge, default));
|
||||
Assert.Null(_rounds.Get(taskId)!.DeferredHandoffAt);
|
||||
}
|
||||
|
||||
// ── acceptance 2 + 3: withholding the handoff ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BelowTheThreshold_HandoffIsNotWithheld()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
var deferred = await BuildGuard(Usage(94, ResetsAt))
|
||||
.TryDeferHandoffAsync(taskId, new[] { "a" }, MergeHelperPhase.Merge, default);
|
||||
|
||||
Assert.Null(deferred);
|
||||
Assert.Null(_rounds.Get(taskId)!.DeferredHandoffAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtTheThreshold_HandoffIsWithheldUntilTheResetPlusGrace()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Wait, new[] { "a" }, DateTime.UtcNow));
|
||||
var guard = BuildGuard(Usage(95, ResetsAt));
|
||||
|
||||
var deferred = await guard.TryDeferHandoffAsync(taskId, new[] { "a", "b" }, MergeHelperPhase.Merge, default);
|
||||
|
||||
Assert.Equal(DueAt, deferred);
|
||||
Assert.Empty(HandoffCalls);
|
||||
|
||||
// Still parked before it is due, sent afterwards -- with the handed-off phase and task set,
|
||||
// not the round's own.
|
||||
await BuildGuard(Usage(95, ResetsAt), DueAt.AddMinutes(-1)).TickAsync(default);
|
||||
Assert.Empty(HandoffCalls);
|
||||
|
||||
await BuildGuard(Usage(95, ResetsAt), DueAt).TickAsync(default);
|
||||
var call = Assert.Single(HandoffCalls);
|
||||
Assert.Equal(taskId, call.Args[0]);
|
||||
Assert.Equal(new[] { "a", "b" }, (IReadOnlyList<string>)call.Args[1]!);
|
||||
Assert.Equal(MergeHelperPhase.Merge, call.Args[2]);
|
||||
// A clean handoff must not cost the next session its auto-restart budget.
|
||||
Assert.False(_rounds.Get(taskId)!.AutoRestartUsed);
|
||||
}
|
||||
|
||||
// Utilization can dip back below the threshold while the session finishes its merge; the handoff
|
||||
// that the warning ASKED for still has to wait for the reset.
|
||||
[Fact]
|
||||
public async Task AfterAWarning_HandoffIsWithheldEvenIfUtilizationDropped()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
await BuildGuard(Usage(97, ResetsAt)).TickAsync(default);
|
||||
Assert.Single(Warnings);
|
||||
|
||||
var deferred = await BuildGuard(Usage(80, ResetsAt))
|
||||
.TryDeferHandoffAsync(taskId, new[] { "a" }, MergeHelperPhase.Merge, default);
|
||||
|
||||
Assert.Equal(DueAt, deferred);
|
||||
}
|
||||
|
||||
// No reset time = nothing to wait for. Sending now beats parking a handoff that never fires.
|
||||
[Fact]
|
||||
public async Task NoResetTime_HandsOffImmediately()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
|
||||
var deferred = await BuildGuard(Usage(99, null))
|
||||
.TryDeferHandoffAsync(taskId, new[] { "a" }, MergeHelperPhase.Merge, default);
|
||||
|
||||
Assert.Null(deferred);
|
||||
Assert.Null(_rounds.Get(taskId)!.DeferredHandoffAt);
|
||||
}
|
||||
|
||||
// ── acceptance 4: a parked handoff survives a worker restart ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task WorkerRestart_ReschedulesAParkedHandoff_AndSendsAnOverdueOneAtOnce()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
await BuildGuard(Usage(99, ResetsAt))
|
||||
.TryDeferHandoffAsync(taskId, new[] { "a" }, MergeHelperPhase.Merge, default);
|
||||
|
||||
// Worker restart: nothing but the file carries over.
|
||||
var afterRestart = new HandlerRoundState(RoundsPath);
|
||||
Assert.Equal(DueAt, afterRestart.Get(taskId)!.DeferredHandoffAt);
|
||||
|
||||
// Not due yet -> still parked.
|
||||
await BuildGuard(Usage(99, ResetsAt), Now, afterRestart).TickAsync(default);
|
||||
Assert.Empty(HandoffCalls);
|
||||
|
||||
// Long overdue (the worker was down past the reset) -> goes out on the first tick.
|
||||
await BuildGuard(Usage(99, ResetsAt), DueAt.AddHours(2), afterRestart).TickAsync(default);
|
||||
Assert.Single(HandoffCalls);
|
||||
Assert.Null(afterRestart.Get(taskId)!.DeferredHandoffAt);
|
||||
}
|
||||
|
||||
// One trigger, one state: a parked handoff blocks the auto-restart of the same round, so the
|
||||
// round can never be reopened twice.
|
||||
[Fact]
|
||||
public async Task AParkedHandoff_BlocksTheAutoRestartOfTheSameRound()
|
||||
{
|
||||
var taskId = await SeedHandlerTaskAsync();
|
||||
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
|
||||
var usage = Usage(99, ResetsAt);
|
||||
await BuildGuard(usage).TryDeferHandoffAsync(taskId, new[] { "a" }, MergeHelperPhase.Merge, default);
|
||||
|
||||
var coordinator = new HandlerRestartCoordinator(
|
||||
_db.CreateFactory(), usage, _rounds, new HubBroadcaster(_hub),
|
||||
NullLogger<HandlerRestartCoordinator>.Instance);
|
||||
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
|
||||
|
||||
await BuildGuard(usage, DueAt).TickAsync(default);
|
||||
Assert.Single(HandoffCalls);
|
||||
}
|
||||
}
|
||||
@@ -73,12 +73,20 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Installed);
|
||||
}
|
||||
|
||||
private InteractiveLaunchSpecService BuildService(HandoffRoundTracker? handoffRounds = null) =>
|
||||
private InteractiveLaunchSpecService BuildService(
|
||||
HandoffRoundTracker? handoffRounds = null, HandlerRoundState? roundState = null, DateTime? now = null) =>
|
||||
new(_db.CreateFactory(), _seeder, _registry,
|
||||
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
||||
new GitService(),
|
||||
new WorkerConfig { ClaudeBin = _claudeStubPath },
|
||||
handoffRounds ?? new HandoffRoundTracker());
|
||||
handoffRounds ?? new HandoffRoundTracker(),
|
||||
roundState ?? NewRoundState(),
|
||||
now is { } t ? () => t : null);
|
||||
|
||||
// Never the live ~/.claudeDo/handler-rounds.json -- a test run would otherwise write real
|
||||
// handler rounds into the running app's state.
|
||||
private HandlerRoundState NewRoundState() =>
|
||||
new(Path.Combine(_tempDir, $"handler-rounds-{Guid.NewGuid():N}.json"));
|
||||
|
||||
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
|
||||
{
|
||||
@@ -212,8 +220,15 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation()
|
||||
public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_OpensInUserHomeWithoutSeedingSkills()
|
||||
{
|
||||
_registry.Installed.Add(new SessionSkillEntity
|
||||
{
|
||||
Name = "installed-skill", SourceUrl = "https://example.com/x.git",
|
||||
PinnedRef = "abc", Subpath = "skills/installed-skill", Description = "d",
|
||||
AddedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
var listId = await SeedListAsync();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
@@ -222,11 +237,18 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle,
|
||||
sessionSkillsJson: "[\"installed-skill\"]");
|
||||
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => svc.BuildForTaskAsync(taskId, CancellationToken.None));
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), spec.Cwd);
|
||||
// Seeding here would copy into the user's REAL ~/.claude/skills.
|
||||
Assert.Empty(_seeder.Calls);
|
||||
|
||||
using var readCtx = _db.CreateContext();
|
||||
Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -685,9 +707,19 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> ReadHandlerSystemPromptAsync(string listId, string taskId)
|
||||
private async Task SetPowerUseWindowAsync(string? start, string? end)
|
||||
{
|
||||
var svc = BuildService();
|
||||
using var ctx = _db.CreateContext();
|
||||
await new AppSettingsRepository(ctx).GetAsync();
|
||||
var row = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
row.PowerUseWindowStart = start;
|
||||
row.PowerUseWindowEnd = end;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<string> ReadHandlerSystemPromptAsync(string listId, string taskId, DateTime? now = null)
|
||||
{
|
||||
var svc = BuildService(now: now);
|
||||
var spec = await svc.BuildForMergeHelperAsync(
|
||||
new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
|
||||
var sessionDir = TrackSessionDir(spec);
|
||||
@@ -752,6 +784,67 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
Assert.StartsWith(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
|
||||
}
|
||||
|
||||
/// The window is a third key on top of the global switch and the list opt-in. Both corners
|
||||
/// use the same overnight window, so only the injected clock differs — that is the whole point
|
||||
/// of injecting it rather than reading DateTime.Now inside the composition.
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_PowerUseOutsideWindow_LeavesPromptUntouched()
|
||||
{
|
||||
var repo = Path.Combine(_tempDir, "puOutsideWindow");
|
||||
Directory.CreateDirectory(repo);
|
||||
var listId = await SeedListAsync(workingDir: repo);
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
await SetPowerUseEnabledAsync(true);
|
||||
await SetPowerUseAllowedAsync(listId, true);
|
||||
await SetPowerUseWindowAsync("22:00", "07:00");
|
||||
|
||||
var prompt = await ReadHandlerSystemPromptAsync(
|
||||
listId, taskId, now: new DateTime(2026, 9, 2, 12, 0, 0));
|
||||
|
||||
Assert.DoesNotContain(AddendumMarker, prompt);
|
||||
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_PowerUseInsideWindow_AppendsAddendum()
|
||||
{
|
||||
var repo = Path.Combine(_tempDir, "puInsideWindow");
|
||||
Directory.CreateDirectory(repo);
|
||||
var listId = await SeedListAsync(workingDir: repo);
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
await SetPowerUseEnabledAsync(true);
|
||||
await SetPowerUseAllowedAsync(listId, true);
|
||||
await SetPowerUseWindowAsync("22:00", "07:00");
|
||||
|
||||
var prompt = await ReadHandlerSystemPromptAsync(
|
||||
listId, taskId, now: new DateTime(2026, 9, 2, 23, 30, 0));
|
||||
|
||||
Assert.Contains(AddendumMarker, prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_HalfSetWindow_AppendsAddendumAtAnyTime()
|
||||
{
|
||||
var repo = Path.Combine(_tempDir, "puHalfWindow");
|
||||
Directory.CreateDirectory(repo);
|
||||
var listId = await SeedListAsync(workingDir: repo);
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
await SetPowerUseEnabledAsync(true);
|
||||
await SetPowerUseAllowedAsync(listId, true);
|
||||
await SetPowerUseWindowAsync("22:00", null);
|
||||
|
||||
var prompt = await ReadHandlerSystemPromptAsync(
|
||||
listId, taskId, now: new DateTime(2026, 9, 2, 12, 0, 0));
|
||||
|
||||
Assert.Contains(AddendumMarker, prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user