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.
213 lines
6.9 KiB
C#
213 lines
6.9 KiB
C#
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels;
|
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
|
using Xunit;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels.MissionControl;
|
|
|
|
public class ConPtyPaneViewModelTests
|
|
{
|
|
private static ConPtyPaneViewModel NewTaskPane(
|
|
Func<Task<TerminalLaunchDescriptor>> descriptorFactory,
|
|
string taskId = "t1")
|
|
=> new(taskId, "Some Task", descriptorFactory);
|
|
|
|
private static Task<TerminalLaunchDescriptor> NeverCompletes()
|
|
=> new TaskCompletionSource<TerminalLaunchDescriptor>().Task;
|
|
|
|
private static Task<TerminalLaunchDescriptor> Failing(string message = "boom")
|
|
=> Task.FromException<TerminalLaunchDescriptor>(new InvalidOperationException(message));
|
|
|
|
// ── CanSubmitForReview gating (fix: don't offer Submit for Review on a dead/starting pane) ──
|
|
|
|
[Fact]
|
|
public void CanSubmitForReview_False_WhileStarting()
|
|
{
|
|
using var pane = NewTaskPane(NeverCompletes);
|
|
pane.Start();
|
|
|
|
Assert.True(pane.Terminal.IsStarting);
|
|
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanSubmitForReview_False_AfterLaunchFailure()
|
|
{
|
|
using var pane = NewTaskPane(() => Failing());
|
|
pane.Start();
|
|
|
|
Assert.NotNull(pane.Terminal.StartError);
|
|
Assert.True(pane.Terminal.HasExited);
|
|
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanSubmitForReview_True_WhenRunning()
|
|
{
|
|
using var pane = NewTaskPane(NeverCompletes);
|
|
SetRunning(pane);
|
|
|
|
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
|
|
{
|
|
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
|
|
SetRunning(pane);
|
|
|
|
Assert.False(pane.IsTaskBased);
|
|
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanSubmitForReview_False_WhileSubmitPending()
|
|
{
|
|
using var pane = NewTaskPane(NeverCompletes);
|
|
SetRunning(pane);
|
|
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
|
|
|
|
pane.IsSubmitPending = true;
|
|
|
|
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
|
|
|
|
pane.IsSubmitPending = false;
|
|
|
|
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
|
|
}
|
|
|
|
// ── Retry (fix: a failed launch used to permanently occupy the TaskId dedupe slot) ──────────
|
|
|
|
[Fact]
|
|
public void RetryCommand_Disabled_BeforeAndWhileStarting()
|
|
{
|
|
using var pane = NewTaskPane(NeverCompletes);
|
|
Assert.False(pane.RetryCommand.CanExecute(null));
|
|
|
|
pane.Start();
|
|
|
|
Assert.False(pane.RetryCommand.CanExecute(null)); // still starting, no failure yet
|
|
}
|
|
|
|
[Fact]
|
|
public void RetryCommand_Enabled_AfterLaunchFailure()
|
|
{
|
|
using var pane = NewTaskPane(() => Failing());
|
|
pane.Start();
|
|
|
|
Assert.True(pane.RetryCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Retry_ReplacesTerminal_AndRefetchesDescriptor()
|
|
{
|
|
var callCount = 0;
|
|
Func<Task<TerminalLaunchDescriptor>> factory = () =>
|
|
{
|
|
callCount++;
|
|
return callCount == 1 ? Failing() : NeverCompletes();
|
|
};
|
|
|
|
using var pane = NewTaskPane(factory);
|
|
pane.Start();
|
|
Assert.True(pane.RetryCommand.CanExecute(null));
|
|
var terminalBeforeRetry = pane.Terminal;
|
|
|
|
pane.RetryCommand.Execute(null);
|
|
|
|
Assert.Equal(2, callCount);
|
|
Assert.NotSame(terminalBeforeRetry, pane.Terminal);
|
|
Assert.Null(pane.Terminal.StartError);
|
|
Assert.False(pane.Terminal.HasExited);
|
|
Assert.False(pane.RetryCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Retry_FailsAgain_StillOffersRetry()
|
|
{
|
|
using var pane = NewTaskPane(() => Failing());
|
|
pane.Start();
|
|
Assert.True(pane.RetryCommand.CanExecute(null));
|
|
|
|
pane.RetryCommand.Execute(null);
|
|
|
|
Assert.NotNull(pane.Terminal.StartError);
|
|
Assert.True(pane.RetryCommand.CanExecute(null));
|
|
}
|
|
|
|
// ── Died-at-startup (fix: an immediate ConPTY exit used to leave StartError null, so Retry
|
|
// never became reachable and the pane was stuck reusing the same persisted session id) ──
|
|
|
|
[Fact]
|
|
public void RetryCommand_Enabled_AfterProcessExitsNonzeroRightAfterLaunch()
|
|
{
|
|
using var pane = NewTaskPane(NeverCompletes);
|
|
SetRunning(pane);
|
|
Assert.False(pane.RetryCommand.CanExecute(null));
|
|
|
|
pane.Terminal.OnSessionProcessExited(null, 1);
|
|
|
|
Assert.NotNull(pane.Terminal.StartError);
|
|
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);
|
|
}
|
|
}
|