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 readonly List _waited = new(); private HandlerRestartCoordinator BuildCoordinator(UsageState usage) { var coordinator = new HandlerRestartCoordinator( _db.CreateFactory(), usage, _rounds, new HubBroadcaster(_hub), NullLogger.Instance); coordinator.NowUtc = () => ResetsAt.AddHours(-3); coordinator.Delay = (d, _) => { _waited.Add(d); return Task.CompletedTask; }; return coordinator; } /// 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)); var coordinator = BuildCoordinator(AtUsageLimit()); Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1)); 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]); // Waited out the window instead of relaunching straight into the same limit. Assert.Equal(new[] { TimeSpan.FromHours(3) }, _waited); } // ── 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)); Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1)); 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)); // 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 tools = new HandoffMcpTools( new TaskRepository(ctx), new HubBroadcaster(_hub), tracker, _db.CreateFactory()); var before = await tools.GetHandoffStatus(taskId); Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1)); 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); } }