fix(ui): treat an immediate ConPTY exit as a start failure
InteractiveSessionId is persisted before the ConPTY process spawns (1a988ff).
If `claude --session-id <guid>` exits immediately after launch (auth/network
hiccup, crash before the TUI starts), OnSessionProcessExited set HasExited
but never StartError, so ConPtyPaneViewModel.CanRetry (which requires
StartError) never offered Retry — every reopen just resumed the same dead
session id, permanently poisoning that task's interactive sessions.
Now a nonzero exit within 5s of the session becoming "running" is treated
as a died-at-startup failure and routed through the same StartError path
as a launch-time exception, so the pane shows the error banner + Retry.
Retry re-fetches the LaunchSpec via BuildForTaskAsync, which reuses the
same persisted InteractiveSessionId — correct for a transient failure
(fresh process, same id), but does not help a genuinely dead session id.
Clearing a dead session id server-side is a separate design question,
left out of scope here.
This commit is contained in:
@@ -134,5 +134,21 @@ public class ConPtyPaneViewModelTests
|
||||
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));
|
||||
}
|
||||
|
||||
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels.MissionControl;
|
||||
|
||||
public class InteractiveTerminalViewModelTests
|
||||
{
|
||||
private static readonly DateTime LaunchedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── Died-at-startup detection (fix: an immediate ConPTY exit left StartError null, so
|
||||
// ConPtyPaneViewModel.CanRetry — which requires StartError — never offered Retry) ────────
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_RightAfterLaunch_SetsStartError()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
terminal.IsRunning = true; // simulates a successful ConPTY spawn
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1);
|
||||
|
||||
Assert.False(terminal.IsRunning);
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Equal(1, terminal.ExitCode);
|
||||
Assert.NotNull(terminal.StartError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_AfterRunningAWhile_DoesNotSetStartError()
|
||||
{
|
||||
var exitedAt = LaunchedAt.AddSeconds(10);
|
||||
var callCount = 0;
|
||||
using var terminal = new InteractiveTerminalViewModel(() => callCount++ == 0 ? LaunchedAt : exitedAt);
|
||||
terminal.IsRunning = true; // records LaunchedAt via the first clock call
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1); // crash after running for a while
|
||||
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsZero_RightAfterLaunch_DoesNotSetStartError()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
terminal.IsRunning = true;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 0); // user typed exit / session ended cleanly
|
||||
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user