Compare commits
9
Commits
20b3b4ce2b
...
v2.13.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f64ba5058 | ||
|
|
d258ab4164 | ||
|
|
574edf6c1a | ||
|
|
fe8186514f | ||
|
|
c2166a0d4f | ||
|
|
c3a418f077 | ||
|
|
2ec3af96d5 | ||
|
|
016b9203f8 | ||
|
|
69898be5c9 |
@@ -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) →
|
||||
|
||||
@@ -41,10 +41,18 @@ Beyond the basics it carries:
|
||||
| `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. |
|
||||
| `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. Stored + shown on the usage monitor; 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,
|
||||
|
||||
@@ -69,6 +69,17 @@ public sealed class AppSettingsEntity
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,13 @@ public record AppSettingsDto(
|
||||
// PowerUse master switch — toggled on the usage monitor, not typed in Settings. 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. Also usage-monitor-owned, so Settings has
|
||||
// to round-trip them too.
|
||||
string? PowerUseWindowStart = null,
|
||||
string? PowerUseWindowEnd = null,
|
||||
int PowerUseHandoffPct = 95);
|
||||
|
||||
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@
|
||||
"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}",
|
||||
@@ -511,6 +512,10 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"refresh": "Jetzt aktualisieren",
|
||||
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
|
||||
"staleFormat": "Werte veraltet (Stand {0})",
|
||||
|
||||
@@ -338,6 +338,7 @@
|
||||
"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}",
|
||||
@@ -511,6 +512,10 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"refresh": "Refresh now",
|
||||
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
|
||||
"staleFormat": "Values stale (as of {0})",
|
||||
|
||||
@@ -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,10 @@ 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>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;
|
||||
@@ -93,6 +94,56 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,9 @@ 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<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
|
||||
|
||||
|
||||
@@ -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,11 @@ 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);
|
||||
|
||||
public void Kill() => _session.Kill();
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -36,6 +36,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// commits the worktree and moves the task to WaitingForReview.</summary>
|
||||
public event Action<string>? SubmitForReviewRequested;
|
||||
|
||||
/// <summary>Raised 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 +55,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 +123,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);
|
||||
|
||||
@@ -111,16 +148,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
|
||||
@@ -98,6 +105,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();
|
||||
}
|
||||
@@ -383,6 +395,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);
|
||||
@@ -406,6 +419,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
|
||||
}
|
||||
|
||||
// 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,
|
||||
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
|
||||
private void AddConPtyPane(ConPtyPaneViewModel pane)
|
||||
@@ -413,10 +439,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||
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)
|
||||
{
|
||||
@@ -457,6 +501,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
pane.ErrorReported -= OnConPtyPaneError;
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
ConPtySessions.Remove(pane);
|
||||
pane.Dispose();
|
||||
}
|
||||
@@ -500,6 +545,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 +554,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
c.ErrorReported -= OnConPtyPaneError;
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
c.Dispose();
|
||||
}
|
||||
ConPtySessions.Clear();
|
||||
|
||||
@@ -134,6 +134,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
// opening and saving Settings can't switch it off behind the user's back.
|
||||
private bool _powerUseEnabled;
|
||||
|
||||
// Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible
|
||||
// here, but they must survive a Settings save.
|
||||
private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95);
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
@@ -156,6 +160,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
|
||||
// the record default (false) back and silently switch PowerUse off.
|
||||
_powerUseEnabled = dto.PowerUseEnabled;
|
||||
_powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct);
|
||||
_throttleStages = (
|
||||
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
|
||||
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
|
||||
@@ -224,7 +229,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
_throttleStages.SevenHard,
|
||||
General.AutoContinueOnUsageLimit,
|
||||
_powerUseEnabled,
|
||||
General.ListHandlerMaxHandoffs);
|
||||
General.ListHandlerMaxHandoffs,
|
||||
_powerUseWindow.Start,
|
||||
_powerUseWindow.End,
|
||||
_powerUseWindow.HandoffPct);
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -108,6 +109,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
[ObservableProperty] private bool _powerUseEnabled;
|
||||
|
||||
/// <summary>Optional local-time window (<c>HH:mm</c>) narrowing when PowerUse applies. Both
|
||||
/// blank = round the clock. Committed on focus loss / Enter, not per keystroke.</summary>
|
||||
[ObservableProperty] private string? _powerUseWindowStart;
|
||||
[ObservableProperty] private string? _powerUseWindowEnd;
|
||||
|
||||
/// <summary>5h-usage percentage at which a PowerUse handler session should hand off.</summary>
|
||||
[ObservableProperty] private int _powerUseHandoffPct = 95;
|
||||
|
||||
// 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;
|
||||
@@ -115,13 +124,16 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
partial void OnPowerUseEnabledChanged(bool value)
|
||||
{
|
||||
if (_loadingPowerUse) return;
|
||||
_ = SavePowerUseAsync(value);
|
||||
_ = SavePowerUse();
|
||||
}
|
||||
|
||||
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
|
||||
/// doesn't own.
|
||||
private async Task SavePowerUseAsync(bool value)
|
||||
/// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a
|
||||
/// value the user just typed into a sibling box.
|
||||
[RelayCommand]
|
||||
private async Task SavePowerUse()
|
||||
{
|
||||
if (_loadingPowerUse) return;
|
||||
try
|
||||
{
|
||||
var settings = await _worker.GetAppSettingsAsync();
|
||||
@@ -131,7 +143,28 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
|
||||
// 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.
|
||||
var start = PowerUseWindow.Normalize(PowerUseWindowStart);
|
||||
var end = PowerUseWindow.Normalize(PowerUseWindowEnd);
|
||||
var pct = Math.Clamp(PowerUseHandoffPct, 50, 99);
|
||||
|
||||
await _worker.UpdateAppSettingsAsync(settings with
|
||||
{
|
||||
PowerUseEnabled = PowerUseEnabled,
|
||||
PowerUseWindowStart = start,
|
||||
PowerUseWindowEnd = end,
|
||||
PowerUseHandoffPct = pct,
|
||||
});
|
||||
|
||||
_loadingPowerUse = true;
|
||||
try
|
||||
{
|
||||
PowerUseWindowStart = start;
|
||||
PowerUseWindowEnd = end;
|
||||
PowerUseHandoffPct = pct;
|
||||
}
|
||||
finally { _loadingPowerUse = false; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -192,7 +225,13 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
if (settings is not null)
|
||||
{
|
||||
_loadingPowerUse = true;
|
||||
try { PowerUseEnabled = settings.PowerUseEnabled; }
|
||||
try
|
||||
{
|
||||
PowerUseEnabled = settings.PowerUseEnabled;
|
||||
PowerUseWindowStart = settings.PowerUseWindowStart;
|
||||
PowerUseWindowEnd = settings.PowerUseWindowEnd;
|
||||
PowerUseHandoffPct = settings.PowerUseHandoffPct;
|
||||
}
|
||||
finally { _loadingPowerUse = false; }
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,29 @@
|
||||
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
|
||||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||||
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
|
||||
<!-- Window + handoff threshold. Same commit-on-blur/Enter discipline as the gauge
|
||||
legend boxes: a hub write per keystroke would be one save per typed digit. -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center"
|
||||
Text="{loc:Tr modals.usageMonitor.powerUseWindow}"/>
|
||||
<TextBox Width="70" FontSize="12" Padding="6,2"
|
||||
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
|
||||
Text="{Binding PowerUseWindowStart, Mode=TwoWay}"
|
||||
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center" Text="–"/>
|
||||
<TextBox Width="70" FontSize="12" Padding="6,2"
|
||||
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
|
||||
Text="{Binding PowerUseWindowEnd, Mode=TwoWay}"
|
||||
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center" Margin="12,0,0,0"
|
||||
Text="{loc:Tr modals.usageMonitor.powerUseHandoffPct}"/>
|
||||
<NumericUpDown Width="80" FontSize="12" Padding="6,2" ShowButtonSpinner="False"
|
||||
Minimum="50" Maximum="99" Increment="1" FormatString="0"
|
||||
Value="{Binding PowerUseHandoffPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="meta" TextWrapping="Wrap" Opacity="0.75"
|
||||
Text="{loc:Tr modals.usageMonitor.powerUseWindowHint}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -33,4 +33,19 @@ public partial class UsageMonitorModalView : Window
|
||||
OnStageBoxCommit(sender, e);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>Persists the PowerUse window / handoff threshold on blur — a save per keystroke
|
||||
/// would be one hub round-trip per typed digit.</summary>
|
||||
private void OnPowerUseBoxCommit(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not UsageMonitorModalViewModel vm) return;
|
||||
if (vm.SavePowerUseCommand.CanExecute(null)) vm.SavePowerUseCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void OnPowerUseBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter) return;
|
||||
OnPowerUseBoxCommit(sender, e);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -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);
|
||||
|
||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
||||
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced);
|
||||
// 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, 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,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly TicketPatStore _ticketPat;
|
||||
private readonly TicketClientFactory _ticketClients;
|
||||
private readonly TicketImportService _ticketImport;
|
||||
private readonly HandlerRestartCoordinator? _handlerRestart;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -110,7 +111,8 @@ 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)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -149,6 +151,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_ticketPat = ticketPat;
|
||||
_ticketClients = ticketClients;
|
||||
_ticketImport = ticketImport;
|
||||
_handlerRestart = handlerRestart;
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
@@ -323,7 +326,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 +369,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 +798,18 @@ 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);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -95,6 +95,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 +341,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 +360,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 +381,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,6 +68,7 @@ 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.
|
||||
@@ -342,9 +350,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 +468,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);
|
||||
}
|
||||
|
||||
@@ -580,7 +604,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 +616,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;
|
||||
|
||||
|
||||
@@ -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,8 @@ 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<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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,101 @@
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
/// The PowerUse window and handoff threshold are edited on the usage monitor, but Settings→General
|
||||
/// rebuilds the whole 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: AppSettingsRepository.UpdateAsync deliberately doesn't
|
||||
// copy PowerUseEnabled (the usage monitor owns that toggle, not the settings modal).
|
||||
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: AppSettingsRepository.UpdateAsync deliberately doesn't
|
||||
// copy PowerUseEnabled (the usage monitor owns that toggle, not the settings modal).
|
||||
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")
|
||||
{
|
||||
@@ -685,9 +693,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 +770,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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user