Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Runner/HandlerRestartCoordinatorTests.cs
T
mika kuns 7c7c33d677 feat(ui): PowerUse-Einstellungen nach Settings -> Ausfuehrung verschieben
Schalter, Zeitfenster und Handoff-Prozent lagen im Usage-Monitor und mussten
in SettingsModalViewModel nur load->save durchgeschleift werden, damit ein
Settings-Speichern sie nicht zurueckwirft. Jetzt gehoeren sie dem General-Tab,
der Monitor ist wieder nur Anzeige + Threshold-Drag, und "Jetzt aktualisieren"
sitzt als Icon in der Titelleiste (neuer ModalShell.HeaderActions-Slot).
2026-09-07 08:17:37 +02:00

297 lines
13 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 so the fixture doesn't depend on the repository's
// clamping/normalizing copy block.
await new AppSettingsRepository(ctx).GetAsync(); // materializes the singleton row
var settings = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
settings.PowerUseEnabled = powerUseEnabled;
await ctx.SaveChangesAsync();
}
return taskId;
}
private IReadOnlyList<CapturedHubCall> HandoffCalls =>
_hub.Proxy.Calls.Where(c => c.Method == "HandoffRequested").ToList();
// ── acceptance 1: same phase, same task set, after the reset ────────────────────────────────
[Fact]
public async Task UsageLimitDeath_ReopensSamePhaseWithSameTaskSet_AfterTheReset()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a", "b" }, DateTime.UtcNow));
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
// Parked, not relaunched straight into the same exhausted window.
Assert.Empty(HandoffCalls);
Assert.Equal(DueAt, _rounds.Get(taskId)!.DeferredHandoffAt);
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
var call = Assert.Single(HandoffCalls);
Assert.Equal(taskId, call.Args[0]);
Assert.Equal(new[] { "a", "b" }, (IReadOnlyList<string>)call.Args[1]!);
Assert.Equal(MergeHelperPhase.Merge, call.Args[2]);
}
// The #272 review finding: the restart budget used to be spent when the wait STARTED, so a
// worker that died during the hours-long wait left the round marked "restart used" without one
// ever having happened -- and the session stayed dead forever.
[Fact]
public async Task ParkedRestart_SurvivesAWorkerRestart_AndOnlySpendsTheBudgetOnceItFires()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
// Worker restart: a fresh state object re-reads the same file, nothing else carries over.
var afterRestart = new HandlerRoundState(Path.Combine(_tempDir, "handler-rounds.json"));
Assert.False(afterRestart.Get(taskId)!.AutoRestartUsed);
Assert.Equal(DueAt, afterRestart.Get(taskId)!.DeferredHandoffAt);
// Still parked while the window is open...
await BuildGuard(AtUsageLimit(), DueAt.AddMinutes(-5)).TickAsync(default);
Assert.Empty(HandoffCalls);
// ...and sent as soon as it is due, which is also when the budget is spent.
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
Assert.Single(HandoffCalls);
Assert.True(_rounds.Get(taskId)!.AutoRestartUsed);
Assert.Null(_rounds.Get(taskId)!.DeferredHandoffAt);
}
// ── acceptance 2: at most one auto-restart per round ───────────────────────────────────────
[Fact]
public async Task SecondDeathInTheSameRound_DoesNotRestartAgain()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.Single(HandoffCalls);
}
// A second death while the first restart is still parked must not overwrite or duplicate it.
[Fact]
public async Task SecondDeathWhileTheRestartIsStillParked_ChangesNothing()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
Assert.Single(HandoffCalls);
}
// The restart relaunches the SAME phase, which writes the round again through
// InteractiveLaunchSpecService -- that re-save must not hand the round a fresh restart budget.
[Fact]
public async Task RestartRebuildingTheSamePhase_DoesNotRefreshTheRestartBudget()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
// The relaunched session's launch spec writes the round again, same phase.
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.Single(HandoffCalls);
}
// ── acceptance 3: PowerUse gates it ────────────────────────────────────────────────────────
[Fact]
public async Task ListWithoutPowerUseOptIn_NoRestart()
{
var taskId = await SeedHandlerTaskAsync(powerUseAllowed: false);
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.Empty(HandoffCalls);
}
[Fact]
public async Task PowerUseGloballyOff_NoRestart()
{
var taskId = await SeedHandlerTaskAsync(powerUseEnabled: false);
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.Empty(HandoffCalls);
}
// ── acceptance 4: a restart spends no handoff budget ───────────────────────────────────────
// handoff_list_handler is the only caller of HandoffRoundTracker.Register, and the restart never
// goes through it -- so a run interrupted by the usage limit does not lose a round of its
// wait/merge chain. Asserted against the tool the handler session itself reads the budget from.
[Fact]
public async Task Restart_DoesNotConsumeHandoffBudget()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var tracker = new HandoffRoundTracker();
tracker.Register(taskId, MergeHelperPhase.Merge, 4); // the run's real handoff into this phase
using var ctx = _db.CreateContext();
var usage = AtUsageLimit();
var tools = new HandoffMcpTools(
new TaskRepository(ctx), new HubBroadcaster(_hub), tracker, _db.CreateFactory(),
BuildGuard(usage, ResetsAt));
var before = await tools.GetHandoffStatus(taskId);
Assert.True(await BuildCoordinator(usage).OnSessionExitedAsync(taskId, 1));
await BuildGuard(usage, DueAt).TickAsync(default);
var after = await tools.GetHandoffStatus(taskId);
Assert.Equal(before.Round, after.Round);
Assert.Equal(before.Remaining, after.Remaining);
Assert.False(after.Final);
}
// ── proof requirement: a non-zero exit alone is not enough ─────────────────────────────────
// The user closed the session -- exit 0 even sitting at the usage limit.
[Fact]
public async Task CleanExit_NoRestart()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
Assert.False(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 0));
Assert.Empty(HandoffCalls);
}
[Fact]
public async Task NonZeroExit_WellBelowTheLimit_IsTreatedAsACrash_NoRestart()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
Assert.False(await BuildCoordinator(AtUsageLimit(percent: 40)).OnSessionExitedAsync(taskId, 1));
Assert.Empty(HandoffCalls);
}
[Fact]
public async Task NoUsageSnapshot_FailsClosed_NoRestart()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
Assert.False(await BuildCoordinator(new UsageState()).OnSessionExitedAsync(taskId, 1));
Assert.Empty(HandoffCalls);
}
// ── not a handler round at all ─────────────────────────────────────────────────────────────
[Fact]
public async Task PlainInteractiveSession_HasNoRound_NoRestart()
{
var taskId = await SeedHandlerTaskAsync();
Assert.False(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
Assert.Empty(HandoffCalls);
}
// The triage round has no handoff launch spec, so there is nothing to reopen it with.
[Fact]
public async Task TriageRound_IsNotReopened()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(
taskId, InteractiveLaunchSpecService.TriagePhase, new[] { "a" }, DateTime.UtcNow));
Assert.False(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
Assert.Empty(HandoffCalls);
}
}