fix: retry CLI passthrough launch without resume flag after fast-fail (#810)
This commit is contained in:
@@ -45,6 +45,15 @@ func main() {
|
||||
if parseTUIFlag() {
|
||||
resumeID := parseResumeFlag()
|
||||
resumed := resumeID != "" || parseContinueFlag()
|
||||
// --fail-on-resume simulates the real Claude CLI's "No conversation
|
||||
// found to continue" behaviour: when invoked with -c / --resume but
|
||||
// the local conversation history is gone, the CLI prints the error
|
||||
// and exits 1. Used by e2e tests to drive the lifecycle manager's
|
||||
// resume-fallback path.
|
||||
if resumed && parseFailOnResumeFlag() {
|
||||
fmt.Print("\033[38;2;255;107;128mNo conversation found to continue\033[39m\r\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
runTUI(model, parsePromptFlag(), resumed)
|
||||
return
|
||||
}
|
||||
@@ -283,6 +292,20 @@ func parseContinueFlag() bool {
|
||||
return slices.Contains(os.Args[1:], "-c")
|
||||
}
|
||||
|
||||
// parseFailOnResumeFlag checks if --fail-on-resume is present in the
|
||||
// command-line args. When set together with a resume flag (-c / --resume),
|
||||
// the mock-agent emits a "No conversation found to continue" message and
|
||||
// exits 1, matching the real Claude CLI's behaviour and exercising the
|
||||
// lifecycle manager's resume-fallback path.
|
||||
func parseFailOnResumeFlag() bool {
|
||||
return parseFailOnResumeFromArgs(os.Args)
|
||||
}
|
||||
|
||||
// parseFailOnResumeFromArgs reports whether --fail-on-resume is in args.
|
||||
func parseFailOnResumeFromArgs(args []string) bool {
|
||||
return slices.Contains(args[1:], "--fail-on-resume")
|
||||
}
|
||||
|
||||
// mcpConfigPayload is the JSON structure for --mcp-config.
|
||||
type mcpConfigPayload struct {
|
||||
MCPServers map[string]mcpServerDef `json:"mcpServers"`
|
||||
|
||||
@@ -336,3 +336,39 @@ func TestParseResumeFromArgs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFailOnResumeFromArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "absent",
|
||||
args: []string{"mock-agent", "--tui"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "present alone",
|
||||
args: []string{"mock-agent", "--tui", "--fail-on-resume"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "present with -c",
|
||||
args: []string{"mock-agent", "--tui", "-c", "--fail-on-resume"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "present interleaved with other flags",
|
||||
args: []string{"mock-agent", "--fail-on-resume", "--tui", "--model", "mock-fast"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := parseFailOnResumeFromArgs(tt.args); got != tt.want {
|
||||
t.Errorf("parseFailOnResumeFromArgs(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,12 +233,21 @@ func (m *Manager) GetExecutionIDForSession(_ context.Context, sessionID string)
|
||||
//
|
||||
// Returns the execution with a running passthrough process, or an error.
|
||||
func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID string) (*AgentExecution, error) {
|
||||
// Check if execution already exists with a running passthrough process
|
||||
// Check if execution already exists with a running passthrough process.
|
||||
// PassthroughProcessID is not cleared on exit, so a stale ID can point at
|
||||
// a dead process; verify the runner still has it before short-circuiting,
|
||||
// otherwise a fast-failed resume launch would keep returning the dead ID
|
||||
// and the WS handler's IsProcessReadyOrPending check would 503 forever.
|
||||
if execution, exists := m.executionStore.GetBySessionID(sessionID); exists {
|
||||
if execution.PassthroughProcessID != "" {
|
||||
return execution, nil
|
||||
if runner := m.GetInteractiveRunner(); runner != nil && runner.IsProcessReadyOrPending(execution.PassthroughProcessID) {
|
||||
return execution, nil
|
||||
}
|
||||
m.logger.Info("execution has stale passthrough process ID, relaunching",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.String("stale_process_id", execution.PassthroughProcessID))
|
||||
}
|
||||
// Execution exists but no passthrough process - will try to start it
|
||||
return m.resumeExistingExecution(ctx, sessionID, execution)
|
||||
}
|
||||
|
||||
|
||||
@@ -313,6 +313,7 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE
|
||||
|
||||
execution.PassthroughProcessID = processInfo.ID
|
||||
execution.PassthroughStartedAt = time.Now()
|
||||
execution.passthroughLaunchUsedResume = false
|
||||
|
||||
m.logger.Info("passthrough session started",
|
||||
zap.String("execution_id", execution.ID),
|
||||
@@ -418,6 +419,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
|
||||
// 4. Update execution with new process ID
|
||||
execution.PassthroughProcessID = processInfo.ID
|
||||
execution.PassthroughStartedAt = time.Now()
|
||||
execution.passthroughLaunchUsedResume = false
|
||||
|
||||
m.logger.Info("passthrough process restarted with fresh context",
|
||||
zap.String("execution_id", execution.ID),
|
||||
@@ -452,10 +454,15 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string
|
||||
return err
|
||||
}
|
||||
|
||||
// Build the resume command
|
||||
// Skip the resume flag if a previous resume already fast-failed for this
|
||||
// execution: re-attaching `-c` / `--resume` would just reproduce the same
|
||||
// "No conversation found to continue" exit on every WS reconnect after
|
||||
// backend restart. Once the sticky flag is set, every subsequent launch
|
||||
// for this execution starts fresh.
|
||||
useResume := !execution.passthroughResumeFailed
|
||||
cmd := resolved.agent.BuildPassthroughCommand(agents.PassthroughOptions{
|
||||
Model: profileModel(resolved.profile),
|
||||
Resume: true,
|
||||
Resume: useResume,
|
||||
PermissionValues: profilePermissionValues(resolved.profile),
|
||||
CLIFlagTokens: m.profileCLIFlagTokens(resolved.profile),
|
||||
})
|
||||
@@ -466,6 +473,7 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string
|
||||
m.logger.Info("resuming passthrough session",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.Bool("use_resume", useResume),
|
||||
zap.Strings("command", cmd.Args()))
|
||||
|
||||
interactiveRunner := m.GetInteractiveRunner()
|
||||
@@ -487,8 +495,9 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string
|
||||
return fmt.Errorf("failed to start passthrough session: %w", err)
|
||||
}
|
||||
|
||||
execution.PassthroughProcessID = processInfo.ID
|
||||
execution.PassthroughStartedAt = time.Now()
|
||||
execution.passthroughLaunchUsedResume = useResume
|
||||
execution.PassthroughProcessID = processInfo.ID
|
||||
|
||||
m.logger.Info("passthrough session resumed",
|
||||
zap.String("session_id", sessionID),
|
||||
@@ -594,10 +603,24 @@ func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpd
|
||||
if status.Status == agentctltypes.ProcessStatusExited || status.Status == agentctltypes.ProcessStatusFailed {
|
||||
// Only trigger auto-restart for the passthrough process, not for user shell terminals
|
||||
if execution.PassthroughProcessID != "" && status.ProcessID == execution.PassthroughProcessID {
|
||||
// Snapshot the start time synchronously so the goroutine doesn't
|
||||
// race the next launch's write to execution.PassthroughStartedAt.
|
||||
// Snapshot the start time and resume-launch flag synchronously so the
|
||||
// goroutine doesn't race the next launch's writes to those fields.
|
||||
startedAt := execution.PassthroughStartedAt
|
||||
go m.handlePassthroughExit(execution, status, startedAt)
|
||||
usedResume := execution.passthroughLaunchUsedResume
|
||||
// Detect fast-fail synchronously so we can flip the resume-failed
|
||||
// flag before the next WS reconnect arrives. The goroutine below
|
||||
// would otherwise miss this race: when the PTY exits the WS bridge
|
||||
// closes too, and by the time the goroutine runs (after a 100ms
|
||||
// cleanup delay) HasActiveWebSocketBySession returns false and it
|
||||
// bails without setting any flags. Scoped to fast-fail+usedResume
|
||||
// so a healthy resumed session that exits cleanly or crashes long
|
||||
// after launch keeps its resume intent for auto-restart.
|
||||
if usedResume && passthroughExitIsFastFail(startedAt, status) {
|
||||
execution.passthroughResumeFailed = true
|
||||
execution.isResumedSession = false
|
||||
execution.passthroughLaunchUsedResume = false
|
||||
}
|
||||
go m.handlePassthroughExit(execution, status, startedAt, usedResume)
|
||||
} else {
|
||||
m.logger.Debug("process exited but not the passthrough process, skipping auto-restart",
|
||||
zap.String("session_id", status.SessionID),
|
||||
@@ -609,10 +632,10 @@ func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpd
|
||||
|
||||
// handlePassthroughExit handles auto-restart logic when a passthrough process exits.
|
||||
// This function is called asynchronously to allow the old process to be cleaned up first.
|
||||
// startedAt is the snapshot of execution.PassthroughStartedAt taken synchronously
|
||||
// at the call site — passed in rather than re-read here to avoid racing with
|
||||
// the next launch's write to that field.
|
||||
func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate, startedAt time.Time) {
|
||||
// startedAt and usedResume are snapshots of the matching execution fields taken
|
||||
// synchronously at the call site — passed in rather than re-read here to avoid
|
||||
// racing with the next launch's writes to those fields.
|
||||
func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate, startedAt time.Time, usedResume bool) {
|
||||
const restartDelay = 500 * time.Millisecond
|
||||
const cleanupDelay = 100 * time.Millisecond // Wait for old process cleanup
|
||||
// fastFailWindow is short enough to catch launch-time failures (bad CLI
|
||||
@@ -672,13 +695,98 @@ func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agent
|
||||
// auth failure). Restarting just thrashes — the next run hits the same
|
||||
// failure at the same speed. Surface the failure to the user instead.
|
||||
if isFastFailExit(startedAt, exitedAt, exitCode, fastFailWindow) {
|
||||
m.notifyFastFailExit(interactiveRunner, sessionID, exitedAt.Sub(startedAt), exitCode, fastFailWindow)
|
||||
uptime := exitedAt.Sub(startedAt)
|
||||
// If the failed launch was a resume (e.g. `--resume <id>` or `-c`),
|
||||
// the most likely cause is a stale conversation ID after a backend
|
||||
// restart — "No conversation found to continue". Retry once with a
|
||||
// fresh command (no resume flag) before giving up.
|
||||
if usedResume {
|
||||
// Resume-failed flags have already been flipped synchronously in
|
||||
// handlePassthroughStatus (see passthroughExitIsFastFail) so any
|
||||
// concurrent WS reconnect that races this goroutine sees the new
|
||||
// values immediately.
|
||||
m.attemptResumeFallback(execution, interactiveRunner, sessionID, exitCode, uptime)
|
||||
return
|
||||
}
|
||||
m.notifyFastFailExit(interactiveRunner, sessionID, uptime, exitCode, fastFailWindow)
|
||||
return
|
||||
}
|
||||
|
||||
m.attemptPassthroughRestart(execution, interactiveRunner, sessionID, exitCode, restartDelay)
|
||||
}
|
||||
|
||||
// attemptResumeFallback recovers from a fast-failed resume launch by relaunching
|
||||
// once with a fresh command (no resume flag). This handles the common case where
|
||||
// the local CLI's conversation history is gone after a backend restart and
|
||||
// `claude -c` / `claude --resume <id>` exits with "No conversation found to
|
||||
// continue". On a successful fallback the user keeps a working session; on
|
||||
// continued failure we surface the existing red banner so they can fix their
|
||||
// profile.
|
||||
func (m *Manager) attemptResumeFallback(execution *AgentExecution, runner *process.InteractiveRunner, sessionID string, exitCode int, uptime time.Duration) {
|
||||
m.logger.Info("passthrough resume launch fast-failed, retrying without resume flag",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.Int("exit_code", exitCode),
|
||||
zap.Duration("uptime", uptime))
|
||||
|
||||
if m.IsShuttingDown() {
|
||||
m.logger.Debug("skipping passthrough resume fallback during shutdown",
|
||||
zap.String("session_id", sessionID))
|
||||
return
|
||||
}
|
||||
if !runner.HasActiveWebSocketBySession(sessionID) {
|
||||
m.logger.Debug("no active WebSocket, skipping passthrough resume fallback",
|
||||
zap.String("session_id", sessionID))
|
||||
return
|
||||
}
|
||||
|
||||
banner := "\r\n\x1b[33m[No prior conversation to resume — starting a fresh session...]\x1b[0m\r\n"
|
||||
if err := runner.WriteToDirectOutputBySession(sessionID, []byte(banner)); err != nil {
|
||||
m.logger.Debug("failed to write resume-fallback banner to terminal",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution)
|
||||
if err != nil {
|
||||
m.notifyFallbackInfrastructureFailure(runner, sessionID, "build fresh command", err)
|
||||
return
|
||||
}
|
||||
|
||||
env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
|
||||
startReq := buildInteractiveStartRequest(sessionID, execution, pt, env, cmd, true)
|
||||
|
||||
processInfo, err := runner.Start(ctx, startReq)
|
||||
if err != nil {
|
||||
m.notifyFallbackInfrastructureFailure(runner, sessionID, "start fresh process", err)
|
||||
return
|
||||
}
|
||||
|
||||
execution.PassthroughStartedAt = time.Now()
|
||||
execution.passthroughLaunchUsedResume = false
|
||||
execution.PassthroughProcessID = processInfo.ID
|
||||
|
||||
if runner.ConnectSessionWebSocket(processInfo.ID) {
|
||||
m.logger.Info("passthrough resume fallback succeeded",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.String("new_process_id", processInfo.ID))
|
||||
} else {
|
||||
m.logger.Warn("passthrough resume fallback started but failed to reconnect WebSocket",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("new_process_id", processInfo.ID))
|
||||
}
|
||||
|
||||
// Mirror ResumePassthroughSession's post-launch bootstrap so right-panel
|
||||
// shell/git/file features come back too — without this the main terminal
|
||||
// works but the user's shell session and workspace stream stay torn down.
|
||||
m.startPassthroughShell(ctx, execution, "failed to start shell after passthrough resume fallback")
|
||||
if m.streamManager != nil && execution.agentctl != nil && execution.GetWorkspaceStream() == nil {
|
||||
go m.streamManager.connectWorkspaceStream(execution, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// attemptPassthroughRestart announces the restart on the terminal, waits the
|
||||
// restart delay, re-checks shutdown/WebSocket, and resumes the session.
|
||||
// Reconnects the existing WebSocket to the new process on success.
|
||||
@@ -735,6 +843,26 @@ func (m *Manager) attemptPassthroughRestart(execution *AgentExecution, runner *p
|
||||
}
|
||||
}
|
||||
|
||||
// notifyFallbackInfrastructureFailure surfaces an attemptResumeFallback
|
||||
// failure that originated from kandev's own machinery (could not build the
|
||||
// fresh command, could not start the new process) rather than from the
|
||||
// agent CLI itself. The existing fast-fail banner blames a "bad CLI flag,
|
||||
// missing binary, or auth failure" — wrong copy for these paths, which
|
||||
// the user can't fix by editing their profile.
|
||||
func (m *Manager) notifyFallbackInfrastructureFailure(runner *process.InteractiveRunner, sessionID, stage string, err error) {
|
||||
m.logger.Error("passthrough resume fallback failed",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("stage", stage),
|
||||
zap.Error(err))
|
||||
failMsg := fmt.Sprintf("\r\n\x1b[31m[Resume fallback failed: could not %s — %s. Please reconnect to retry.]\x1b[0m\r\n",
|
||||
stage, err.Error())
|
||||
if writeErr := runner.WriteToDirectOutputBySession(sessionID, []byte(failMsg)); writeErr != nil {
|
||||
m.logger.Debug("failed to write resume-fallback infra-failure banner to terminal",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(writeErr))
|
||||
}
|
||||
}
|
||||
|
||||
// notifyFastFailExit logs the fast-fail decision and writes a one-shot
|
||||
// banner to the terminal explaining why the auto-restart was skipped.
|
||||
// uptime is the measured process lifetime (status timestamp minus start
|
||||
@@ -753,6 +881,22 @@ func (m *Manager) notifyFastFailExit(runner *process.InteractiveRunner, sessionI
|
||||
}
|
||||
}
|
||||
|
||||
// passthroughExitIsFastFail wraps isFastFailExit for the synchronous
|
||||
// status-callback path that doesn't yet have the unpacked exit code or
|
||||
// timestamp. Same window/semantics as the goroutine path.
|
||||
func passthroughExitIsFastFail(startedAt time.Time, status *agentctltypes.ProcessStatusUpdate) bool {
|
||||
const fastFailWindow = 2 * time.Second
|
||||
exitCode := 0
|
||||
if status.ExitCode != nil {
|
||||
exitCode = *status.ExitCode
|
||||
}
|
||||
exitedAt := status.Timestamp
|
||||
if exitedAt.IsZero() {
|
||||
exitedAt = time.Now()
|
||||
}
|
||||
return isFastFailExit(startedAt, exitedAt, exitCode, fastFailWindow)
|
||||
}
|
||||
|
||||
// isFastFailExit reports whether a passthrough process exit looks like a
|
||||
// launch failure rather than a runtime exit worth restarting. A zero start
|
||||
// time disables the check (e.g. recovered executions where the start time
|
||||
|
||||
@@ -276,13 +276,42 @@ func TestManager_HandlePassthroughExit_SkipsDuringShutdown(t *testing.T) {
|
||||
status := &agentctltypes.ProcessStatusUpdate{SessionID: "sess-1"}
|
||||
|
||||
start := time.Now()
|
||||
mgr.handlePassthroughExit(execution, status, start)
|
||||
mgr.handlePassthroughExit(execution, status, start, false)
|
||||
if elapsed := time.Since(start); elapsed != 0 {
|
||||
t.Errorf("handlePassthroughExit advanced fake time by %v — did not short-circuit during shutdown", elapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestManager_HandlePassthroughExit_ResumeFallback_SkipsDuringShutdown is the
|
||||
// companion to TestManager_HandlePassthroughExit_SkipsDuringShutdown for the
|
||||
// new fast-fail-with-resume branch — verifies that the resume-fallback path
|
||||
// also short-circuits cleanly during graceful shutdown rather than racing the
|
||||
// teardown.
|
||||
func TestManager_HandlePassthroughExit_ResumeFallback_SkipsDuringShutdown(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
if err := mgr.StopAllAgents(context.Background()); err != nil {
|
||||
t.Fatalf("StopAllAgents returned error: %v", err)
|
||||
}
|
||||
|
||||
execution := &AgentExecution{ID: "exec-1", SessionID: "sess-1"}
|
||||
exitCode := 1
|
||||
now := time.Now()
|
||||
status := &agentctltypes.ProcessStatusUpdate{
|
||||
SessionID: "sess-1",
|
||||
ExitCode: &exitCode,
|
||||
Timestamp: now.Add(100 * time.Millisecond),
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
mgr.handlePassthroughExit(execution, status, now, true /* usedResume */)
|
||||
if elapsed := time.Since(start); elapsed != 0 {
|
||||
t.Errorf("handlePassthroughExit(usedResume=true) advanced fake time by %v — did not short-circuit during shutdown", elapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsFastFailExit covers the predicate that decides whether a passthrough
|
||||
// exit looks like a launch failure (bad CLI flag, missing binary, auth
|
||||
// rejection) and should bypass the auto-restart loop.
|
||||
|
||||
@@ -61,6 +61,18 @@ type AgentExecution struct {
|
||||
// Passthrough mode info (CLI passthrough without ACP)
|
||||
PassthroughProcessID string // Process ID in the interactive runner (empty if not in passthrough mode)
|
||||
PassthroughStartedAt time.Time // When the current passthrough process was launched; used to detect fast-fail exits and skip auto-restart loops
|
||||
// passthroughLaunchUsedResume is true if the current passthrough process was
|
||||
// launched via ResumePassthroughSession with the resume flag attached. The
|
||||
// fast-fail handler reads this to decide whether to retry once with a fresh
|
||||
// command (no resume flag) — covers the "No conversation found to continue"
|
||||
// case where the CLI's local conversation history is gone after a backend
|
||||
// restart.
|
||||
passthroughLaunchUsedResume bool
|
||||
// passthroughResumeFailed sticks once a resume launch fast-fails, so that
|
||||
// subsequent ResumePassthroughSession calls (e.g. from EnsurePassthroughExecution
|
||||
// when the frontend reconnects its terminal WS) build a fresh command
|
||||
// instead of thrashing on the same broken resume flag.
|
||||
passthroughResumeFailed bool
|
||||
|
||||
// isResumedSession is true when this execution was created as part of a session resume
|
||||
// (e.g., after backend restart). Used by StartAgentProcess to route passthrough sessions
|
||||
|
||||
@@ -507,7 +507,7 @@ func (r *InteractiveRunner) wait(proc *interactiveProcess) {
|
||||
)
|
||||
|
||||
// Log buffer contents if process exited with error (helps debug startup failures)
|
||||
// Bump to Warn for early-exit failures (process died within 5s of start) — those
|
||||
// Bump to Error for early-exit failures (process died within 5s of start) — those
|
||||
// are almost always real problems (bad CLI flag, missing binary, auth failure)
|
||||
// rather than the expected user-closes-terminal case. Keep Debug for late exits.
|
||||
if status == types.ProcessStatusFailed && proc.buffer != nil {
|
||||
@@ -530,7 +530,7 @@ func (r *InteractiveRunner) wait(proc *interactiveProcess) {
|
||||
zap.String("output", combinedOutput),
|
||||
}
|
||||
if lifetime < 5*time.Second {
|
||||
r.logger.Warn("interactive process exited early — likely startup failure", fields...)
|
||||
r.logger.Error("interactive process exited early — likely startup failure", fields...)
|
||||
} else {
|
||||
r.logger.Debug("interactive process output before exit", fields...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/**
|
||||
* Regression: a CLI passthrough agent that fast-fails on resume (e.g. real
|
||||
* Claude CLI exits "No conversation found to continue") should auto-recover
|
||||
* by relaunching once with a fresh command (no resume flag) instead of
|
||||
* leaving the user stuck with a red banner.
|
||||
*
|
||||
* Driven via the mock-agent's --fail-on-resume flag: when invoked with
|
||||
* -c / --resume the mock prints the canonical error and exits 1; otherwise
|
||||
* it boots normally. The lifecycle manager's resume-fallback path should
|
||||
* detect the fast-fail and relaunch fresh.
|
||||
*/
|
||||
|
||||
async function createTUIProfileWithFailOnResume(apiClient: ApiClient, name: string) {
|
||||
const { agents } = await apiClient.listAgents();
|
||||
// Resolve the mock agent by identity rather than position — this spec
|
||||
// depends on mock-agent-only behaviour (--fail-on-resume + the "Mock Agent"
|
||||
// header), so picking agents[0] would silently exercise the wrong agent if
|
||||
// listAgents() ever changes order.
|
||||
const mockAgent = agents.find((a) => a.name === "mock-agent");
|
||||
if (!mockAgent) {
|
||||
throw new Error(
|
||||
`mock-agent not found in listAgents() (got ${agents.map((a) => `${a.id}=${a.name}`).join(", ")})`,
|
||||
);
|
||||
}
|
||||
return apiClient.createAgentProfile(mockAgent.id, name, {
|
||||
model: "mock-fast",
|
||||
cli_passthrough: true,
|
||||
cli_flags: [{ description: "fail on resume", flag: "--fail-on-resume", enabled: true }],
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Session resume — CLI fallback after fast-fail", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("relaunches without resume flag when CLI exits fast on -c", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const profile = await createTUIProfileWithFailOnResume(apiClient, "TUI Resume Fallback");
|
||||
|
||||
await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"TUI Resume Fallback Task",
|
||||
profile.id,
|
||||
{
|
||||
description: "hello from resume fallback test",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("TUI Resume Fallback Task");
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForPassthroughLoad();
|
||||
await session.waitForPassthroughLoaded();
|
||||
|
||||
// Initial fresh launch: --fail-on-resume is present but no resume flag,
|
||||
// so the mock-agent boots normally and renders the standard header.
|
||||
await session.expectPassthroughHasText("Mock Agent");
|
||||
|
||||
// Wait for the workflow step to advance so the session is in
|
||||
// WAITING_FOR_INPUT — the precondition for resume on next launch.
|
||||
await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Restart the backend, then reload the page to trigger a session resume.
|
||||
// The first launch attempt will pass -c → mock prints "No conversation
|
||||
// found to continue" and exits 1 → backend clears the resume intent and
|
||||
// the next launch (whether triggered inline by the fallback goroutine or
|
||||
// by the next WS reconnect) goes through the fresh-launch path.
|
||||
await backend.restart();
|
||||
await testPage.reload();
|
||||
|
||||
await session.waitForPassthroughLoad();
|
||||
// Skip waitForPassthroughLoaded — with --fail-on-resume the first PTY
|
||||
// exits before the terminal WS settles, so the loading overlay can stay
|
||||
// up briefly while the backend pivots to the fresh launch. Poll on the
|
||||
// fresh-launch header directly: it only renders when PTY2 is alive and
|
||||
// the WS is connected, which is a stronger signal than the loading flag.
|
||||
await session.expectPassthroughHasText("Mock Agent", 60_000);
|
||||
|
||||
// The (RESUMED) header is reserved for runs where the resume flag was
|
||||
// accepted. Asserting its absence confirms the second launch dropped -c.
|
||||
await session.expectPassthroughNotHasText("RESUMED");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user