Eine PowerUse-Handler-Session soll am 5h-Limit nicht mehr mitten im Merge sterben, sondern vorher selbst sauber uebergeben: - HandlerUsageGuard (neu) warnt die laufende Session genau einmal pro Runde, sobald FiveHour.Utilization den Schwellwert PowerUseHandoffPct erreicht. Die Warnung wird per HandlerUsageWarning-Broadcast an Mission Control geschickt und dort in das Pane der Handler-TaskId getippt. - handoff_list_handler broadcastet den Handoff dann nicht sofort, sondern parkt ihn bis ResetsAt + 60 s; das Ergebnis traegt deferredUntil. Ohne ResetsAt wird wie bisher sofort gesendet. - Der geparkte Handoff liegt im HandlerRoundState (handler-rounds.json) und wird vom Guard-Tick ausgeloest, also auch nach einem Worker-Neustart wieder eingeplant bzw. sofort gesendet, wenn er ueberfaellig ist. - Review-Befund aus #272: HandlerRestartCoordinator wartet nicht mehr selbst bis ResetsAt, sondern parkt den Neustart im selben Slot. AutoRestartUsed wird erst gesetzt, wenn der Broadcast wirklich rausging - ein Worker-Absturz waehrend der Wartezeit laesst die Runde nicht mehr dauerhaft tot zurueck. Zurueckgehaltener Handoff und Auto-Neustart teilen sich einen Slot, also oeffnet nie beides dieselbe Runde. UsageLimitProofPct entfaellt zugunsten von PowerUseHandoffPct. - PtyTerminalSession.SendLineAsync schreibt in die laufende Session. Gegen eine echte claude-Session verifiziert: Text und Enter muessen zwei getrennte Writes sein, die Enter-Bytes kommen von Terminal.GenerateKeyInput.
297 lines
14 KiB
C#
297 lines
14 KiB
C#
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);
|
|
}
|
|
}
|