diff --git a/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts b/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts index dfa8c665e8f..adecb8a76ff 100644 --- a/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts +++ b/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts @@ -26,6 +26,7 @@ import { parseRemoteRuntimePtyId } from '../../../../../shared/remote-runtime/remote-runtime-pty-id' import { isOrcaCloudEnvironmentId } from '@/lib/orca-cloud/orca-cloud-environment-id' +import { ORCA_CLOUD_SESSION_ENV_REFRESH } from '@/lib/orca-cloud/cloud-restart-session-env' import { buildFreshAccountRestartCommand } from '@/lib/agent-session/accounts/agent-account-restart-startup' import type { AccountRestartAgent } from '@/store/terminals/terminal-contracts' import type { usePaneAccountRestartStartup } from './use-pane-account-restart-startup' @@ -80,6 +81,16 @@ export function useReadyPaneAccountRestarts({ type ReadyRestartPane = { id: number; leafId: string } +/** The fresh command; if it can't be built from settings, the agent's plain launch after the env refresh. */ +function freshCommandFor(build: () => string, agent: AccountRestartAgent): string { + try { + return build() + } catch (err) { + console.warn('[orca-cloud] RA-11e fresh command fell back to the plain launch:', err) + return `${ORCA_CLOUD_SESSION_ENV_REFRESH}${agent}` + } +} + function worktreeIdForTab(tabId: string): string | null { for (const [worktreeId, tabs] of Object.entries(useAppStore.getState().tabsByWorktree)) { if (tabs.some((tab) => tab.id === tabId)) { @@ -211,7 +222,10 @@ export function runReadyPaneAccountRestarts< pane.leafId ] ?? null, replacedPtyId: ptyId, - freshCommand: args.buildFreshCommand(agent, pane.leafId) + freshCommand: freshCommandFor( + () => args.buildFreshCommand(agent, pane.leafId), + agent + ) }) ) .then((startedFresh) => { diff --git a/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.test.ts b/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.test.ts index b94462371a6..3f8e809e56f 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.test.ts @@ -113,6 +113,21 @@ describe('freshIfResumeFoundNothing (RA-11e)', () => { }) }) + it('ANSI-coloured output and a late message (after 15 s) are still caught', async () => { + readTail = ['\u001b[31mNo conversation found with session ID: s1\u001b[39m', '$ '] + let reads = 0 + const relaunched = await freshIfResumeFoundNothing({ + // The pane binds its new terminal only after 15 s. + currentPtyId: () => (++reads > 15 ? 'remote:env-1@@term_new' : 'remote:env-1@@term_old'), + replacedPtyId: 'remote:env-1@@term_old', + freshCommand: 'claude', + sleep, + now + }) + expect(relaunched).toBe(true) + expect(calls.filter((c) => c.method === 'terminal.send')).toHaveLength(1) + }) + it('a resume that worked is left alone', async () => { readTail = ['Claude Code v2.1.319', '❯ say hi 1'] const relaunched = await freshIfResumeFoundNothing({ diff --git a/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.ts b/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.ts index 6b6c6300878..f969a741036 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-pane-restart-stop.ts @@ -92,19 +92,30 @@ export async function resumeRemotePaneAgent(args: { /** What Claude prints when `--resume ` names a session that never had a conversation. */ const NO_CONVERSATION = /No conversation found with session ID/i -const RESUME_CHECK_MS = 10_000 +// Claude's own start on a sandbox plus the pane's re-attach can take well over 10 s. +const RESUME_CHECK_MS = 25_000 const RESUME_POLL_MS = 1_000 +// eslint-disable-next-line no-control-regex -- terminal output carries ANSI escapes. +const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*\u0007/g + +/** The pane's terminal handle on its host, from the pane's PTY id (`remote:@@`). */ +function hostTerminalOf(ptyId: string | null): { environmentId: string; handle: string } | null { + const parsed = ptyId ? parseRemoteRuntimePtyId(ptyId) : null + return parsed?.environmentId + ? { environmentId: parsed.environmentId, handle: parsed.handle } + : null +} /** * RA-11e backstop: a recorded session can have no conversation on disk (Claude started, never ran a * turn). Its `--resume` prints "No conversation found…" and exits, leaving a bare shell. Watch the - * relaunched pane briefly; if that happened, start the agent fresh in the same shell, once. - * Returns whether it relaunched fresh. + * relaunched pane's terminal (its new handle) for up to 25 s; if that happened, start the agent fresh + * in the same shell, once. Returns whether it relaunched fresh. */ export async function freshIfResumeFoundNothing(args: { /** The pane's PTY id now (the relaunched terminal), or null while it is still binding. */ currentPtyId: () => string | null - /** The PTY id the restart replaced; the check waits for a different one. */ + /** The PTY id the restart replaced; only a terminal with a different handle is read. */ replacedPtyId: string freshCommand: string sleep?: (ms: number) => Promise @@ -113,31 +124,46 @@ export async function freshIfResumeFoundNothing(args: { const sleep = args.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) const now = args.now ?? Date.now + const replaced = hostTerminalOf(args.replacedPtyId) const deadline = now() + RESUME_CHECK_MS + let lastSeen: string | null = null while (now() < deadline) { await sleep(RESUME_POLL_MS) - const ptyId = args.currentPtyId() - const parsed = ptyId && ptyId !== args.replacedPtyId ? parseRemoteRuntimePtyId(ptyId) : null - if (!parsed?.environmentId) { + const current = hostTerminalOf(args.currentPtyId()) + // Key off the terminal handle, not the whole id: the host may reuse its PTY id on relaunch. + if (!current || current.handle === replaced?.handle) { continue } - const target = { kind: 'environment' as const, environmentId: parsed.environmentId } + if (lastSeen !== current.handle) { + lastSeen = current.handle + console.info('[orca-cloud] RA-11e watching relaunched terminal', current.handle) + } + const target = { kind: 'environment' as const, environmentId: current.environmentId } const read = await callRuntimeRpc<{ terminal?: { tail?: string[] } }>( target, 'terminal.read', - { terminal: parsed.handle, limit: 40 }, + { terminal: current.handle, limit: 80 }, { timeoutMs: 5_000 } - ).catch(() => null) - if (!read?.terminal?.tail?.some((line) => NO_CONVERSATION.test(line))) { + ).catch((err: unknown) => { + console.warn('[orca-cloud] RA-11e terminal.read failed:', err) + return null + }) + const lines = (read?.terminal?.tail ?? []).map((line) => line.replace(ANSI, '')) + if (!lines.some((line) => NO_CONVERSATION.test(line))) { continue } + console.info( + '[orca-cloud] RA-11e resume found no conversation; starting fresh in', + current.handle + ) await callRuntimeRpc( target, 'terminal.send', - { terminal: parsed.handle, text: args.freshCommand, enter: true }, + { terminal: current.handle, text: args.freshCommand, enter: true }, { timeoutMs: 5_000 } ) return true } + console.info('[orca-cloud] RA-11e watch ended without a "no conversation" line', lastSeen) return false }