Merge branch 'claudedo/f2f0c6ee1e974c09bc8a8261978cba34'

feat(worker): Handler-Session vor dem Usage-Limit sauber uebergeben (#275); Konflikte mit start_list_handler aufgeloest, WorkerLog-Aufruf im Fire-and-forget-Pfad wieder abgesichert
This commit is contained in:
mika kuns
2026-09-02 21:50:26 +02:00
19 changed files with 848 additions and 74 deletions
@@ -27,6 +27,7 @@ public abstract class StubWorkerClient : IWorkerClient
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;
@@ -59,6 +60,7 @@ public abstract class StubWorkerClient : IWorkerClient
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);
@@ -922,6 +922,38 @@ public class MissionControlViewModelTests : IDisposable
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()
{
@@ -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.
@@ -31,17 +31,18 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
private readonly List<TimeSpan> _waited = new();
private static readonly DateTimeOffset DueAt = ResetsAt + HandlerUsageGuard.ResetGrace;
private HandlerRestartCoordinator BuildCoordinator(UsageState usage)
{
var coordinator = new HandlerRestartCoordinator(
_db.CreateFactory(), usage, _rounds, new HubBroadcaster(_hub),
private HandlerRestartCoordinator BuildCoordinator(UsageState usage) =>
new(_db.CreateFactory(), usage, _rounds, new HubBroadcaster(_hub),
NullLogger<HandlerRestartCoordinator>.Instance);
coordinator.NowUtc = () => ResetsAt.AddHours(-3);
coordinator.Delay = (d, _) => { _waited.Add(d); return Task.CompletedTask; };
return coordinator;
}
/// 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) =>
@@ -91,16 +92,45 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
{
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));
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]);
// Waited out the window instead of relaunching straight into the same limit.
Assert.Equal(new[] { TimeSpan.FromHours(3) }, _waited);
}
// 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 ───────────────────────────────────────
@@ -112,9 +142,25 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
_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);
}
@@ -128,6 +174,7 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
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));
@@ -176,11 +223,14 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
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());
new TaskRepository(ctx), new HubBroadcaster(_hub), tracker, _db.CreateFactory(),
BuildGuard(usage, ResetsAt));
var before = await tools.GetHandoffStatus(taskId);
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
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);
@@ -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);
}
}