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.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.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(), DateTime.UtcNow)); return state; } private int _nextNumber = 1; private async Task 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 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)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); } }