feat(worker): Handler-Session vor dem Usage-Limit warnen und Handoff bis zum Reset zurueckhalten
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.
This commit is contained in:
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||
public event Action? NotesUpdatedEvent;
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
@@ -57,6 +58,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
||||
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user