fix: retry CLI passthrough launch without resume flag after fast-fail (#810)

This commit is contained in:
Carlos Florêncio
2026-05-04 08:45:20 +01:00
committed by GitHub
parent d4f6d89e3f
commit b01e6abc06
8 changed files with 374 additions and 17 deletions
+23
View File
@@ -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");
});
});