feat(worker): Handler-Session nach Usage-Limit-Reset automatisch neu starten
Eine List-Handler-ConPTY-Session, die mitten in einer Phase stirbt, war bisher
ein stilles totes Tile: InteractiveTerminalViewModel.OnSessionProcessExited
setzte nur bei einem Tod innerhalb des 5s-Grace-Fensters ein sichtbares Banner.
Neu:
- HandlerRoundState (JSON unter ~/.claudeDo/handler-rounds.json, keine
EF-Migration): Phase + Task-Set der laufenden Runde, geschrieben von
BuildForMergeHelperAsync ("triage") und BuildForMergeHelperHandoffAsync.
Ohne das existieren beide nur im transienten HandoffRequested-Broadcast.
- HandlerRestartCoordinator: entscheidet den Neustart, fail-closed. Beleg fuer
"war das Usage-Limit" ist FiveHour.Utilization >= 95 zum Todeszeitpunkt --
eine ConPTY-Session hat kein stdout, TaskRunner.IsUsageLimitMessage hat hier
nichts zu lesen. Nur bei PowerUse (beide Keys), max ein Neustart pro Runde,
Warten bis FiveHour.ResetsAt, dann HandoffRequested erneut broadcasten.
Verbraucht kein Handoff-Budget (HandoffRoundTracker.Register wird nicht
angefasst). WorkerLog-Zeilen beim Tod und beim Neustart.
- Ui meldet den Exit-Code (nur dort bekannt) via
WorkerHub.ReportInteractiveSessionExited, ohne selbst zu filtern.
Sibling zu UsageLimitAutoContinueCoordinator, kein Umbau davon: der sieht nur
headless Runs.
This commit is contained in:
@@ -132,6 +132,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Xunit;
|
||||
|
||||
@@ -150,5 +151,62 @@ public class ConPtyPaneViewModelTests
|
||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SessionDiedAfterStartup_TaskPane_RaisesWithTaskIdAndExitCode()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes, taskId: "handler-1");
|
||||
(string TaskId, int ExitCode)? reported = null;
|
||||
pane.SessionDiedAfterStartup += (id, code) => reported = (id, code);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.Equal(("handler-1", 1), reported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
|
||||
{
|
||||
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
|
||||
var raised = false;
|
||||
pane.SessionDiedAfterStartup += (_, _) => raised = true;
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.False(raised);
|
||||
}
|
||||
|
||||
// Retry swaps in a fresh terminal -- the death of the RETRIED session must still be reported.
|
||||
[Fact]
|
||||
public void SessionDiedAfterStartup_StillWiredAfterRetry()
|
||||
{
|
||||
var callCount = 0;
|
||||
using var pane = NewTaskPane(
|
||||
() => callCount++ == 0 ? Failing() : NeverCompletes(), taskId: "handler-1");
|
||||
pane.Start();
|
||||
pane.RetryCommand.Execute(null);
|
||||
|
||||
var raised = false;
|
||||
pane.SessionDiedAfterStartup += (_, _) => raised = true;
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
|
||||
|
||||
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
|
||||
// time off the real clock, so a sleep-free "ran for a while" needs the exit routed through a
|
||||
// terminal whose clock has already moved on.
|
||||
private static void DieAfterRunning(ConPtyPaneViewModel pane, int exitCode)
|
||||
{
|
||||
var launchedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var callCount = 0;
|
||||
pane.Terminal = new InteractiveTerminalViewModel(
|
||||
() => callCount++ == 0 ? launchedAt : launchedAt.AddMinutes(90));
|
||||
pane.Terminal.IsRunning = true;
|
||||
pane.Terminal.OnSessionProcessExited(null, exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -49,4 +49,54 @@ public class InteractiveTerminalViewModelTests
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
}
|
||||
|
||||
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
|
||||
// the worker turns it into a usage-limit auto-restart for list handlers) ───────────────────
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_AfterRunningAWhile_RaisesExitedAfterStartup()
|
||||
{
|
||||
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
|
||||
int? reported = null;
|
||||
terminal.ExitedAfterStartup += code => reported = code;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1);
|
||||
|
||||
Assert.Equal(1, reported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_WithinGraceWindow_DoesNotRaiseExitedAfterStartup()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
terminal.IsRunning = true;
|
||||
var raised = false;
|
||||
terminal.ExitedAfterStartup += _ => raised = true;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1);
|
||||
|
||||
Assert.False(raised);
|
||||
Assert.NotNull(terminal.StartError); // still the startup-failure banner
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsZero_AfterRunningAWhile_DoesNotRaiseExitedAfterStartup()
|
||||
{
|
||||
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
|
||||
var raised = false;
|
||||
terminal.ExitedAfterStartup += _ => raised = true;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 0);
|
||||
|
||||
Assert.False(raised);
|
||||
}
|
||||
|
||||
private static InteractiveTerminalViewModel NewTerminalExitingAfter(TimeSpan uptime)
|
||||
{
|
||||
var callCount = 0;
|
||||
var terminal = new InteractiveTerminalViewModel(
|
||||
() => callCount++ == 0 ? LaunchedAt : LaunchedAt + uptime);
|
||||
terminal.IsRunning = true; // records LaunchedAt via the first clock call
|
||||
return terminal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,6 +867,61 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
|
||||
}
|
||||
|
||||
// ── usage-limit auto-restart: the exit code has to reach the worker ─────────────────────────
|
||||
|
||||
private sealed class RecordingExitWorker : StubWorkerClient
|
||||
{
|
||||
public readonly List<(string TaskId, int ExitCode)> Reported = new();
|
||||
public override Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||
{
|
||||
Reported.Add((taskId, exitCode));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// A handler tile that ran for a while and then died non-zero used to be a silent dead pane.
|
||||
// Whether it was the usage limit (and worth reopening) is the worker's call, so the UI's only
|
||||
// job is reporting the exit -- hence no filtering here, every task pane reports.
|
||||
[Fact]
|
||||
public async Task PaneSessionDiesAfterRunning_ReportsExitToWorker()
|
||||
{
|
||||
var worker = new RecordingExitWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.Equal(new[] { ("handler-1", 1) }, worker.Reported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosedPane_DoesNotReportItsSessionExit()
|
||||
{
|
||||
var worker = new RecordingExitWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.Empty(worker.Reported);
|
||||
}
|
||||
|
||||
// Drives a pane's terminal past its startup grace window and then kills it. IsRunning stamps the
|
||||
// launch time off the terminal's own clock, so a sleep-free "ran for 90 minutes" needs a fresh
|
||||
// terminal with a stubbed clock -- the pane re-wires its handlers on assignment.
|
||||
private static void DieAfterRunning(ConPtyPaneViewModel pane, int exitCode)
|
||||
{
|
||||
var launchedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var callCount = 0;
|
||||
pane.Terminal = new InteractiveTerminalViewModel(
|
||||
() => callCount++ == 0 ? launchedAt : launchedAt.AddMinutes(90));
|
||||
pane.Terminal.IsRunning = true;
|
||||
pane.Terminal.OnSessionProcessExited(null, exitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleLayoutCommand_FlipsIsFocusMode()
|
||||
{
|
||||
|
||||
@@ -64,7 +64,9 @@ public sealed class MergeHelperTaskHubTests : IDisposable
|
||||
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
|
||||
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
|
||||
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
|
||||
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker());
|
||||
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker(),
|
||||
new ClaudeDo.Worker.Runner.HandlerRoundState(
|
||||
Path.Combine(Path.GetTempPath(), $"claudedo_rounds_{Guid.NewGuid():N}.json")));
|
||||
var built = TaskStateServiceBuilder.Build(factory);
|
||||
var reviewSubmission = new InteractiveReviewSubmissionService(factory, git, built.State, wtManager);
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
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<TimeSpan> _waited = new();
|
||||
|
||||
private HandlerRestartCoordinator BuildCoordinator(UsageState usage)
|
||||
{
|
||||
var coordinator = new HandlerRestartCoordinator(
|
||||
_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 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: 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<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));
|
||||
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<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);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
@@ -73,12 +73,19 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Installed);
|
||||
}
|
||||
|
||||
private InteractiveLaunchSpecService BuildService(HandoffRoundTracker? handoffRounds = null) =>
|
||||
private InteractiveLaunchSpecService BuildService(
|
||||
HandoffRoundTracker? handoffRounds = null, HandlerRoundState? roundState = null) =>
|
||||
new(_db.CreateFactory(), _seeder, _registry,
|
||||
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
||||
new GitService(),
|
||||
new WorkerConfig { ClaudeBin = _claudeStubPath },
|
||||
handoffRounds ?? new HandoffRoundTracker());
|
||||
handoffRounds ?? new HandoffRoundTracker(),
|
||||
roundState ?? NewRoundState());
|
||||
|
||||
// Never the live ~/.claudeDo/handler-rounds.json -- a test run would otherwise write real
|
||||
// handler rounds into the running app's state.
|
||||
private HandlerRoundState NewRoundState() =>
|
||||
new(Path.Combine(_tempDir, $"handler-rounds-{Guid.NewGuid():N}.json"));
|
||||
|
||||
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user