From 07adb39663fa90beb0e09d0f626aa5dbee01b294 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 06:20:49 -0800 Subject: [PATCH] fix(agents): a woken turn-less Claude starts fresh under its id instead of --resume into 'No conversation found' (WAKE-1) A Claude pane that never took a turn has no transcript, so the host's wake relaunch (and an explicit ensureAgentSession resume) ran claude --resume , which printed 'No conversation found' and left a bare shell. When the hook-reported transcript path is local to the host and not on disk, the host now launches claude --session-id with the pane's recorded args (model, effort) instead. Same id, so resume claims, holds and readers still match. A session with a transcript, a non-Claude agent, an SSH host, or no recorded path keep the plain resume. #425 stays the desktop backstop. --- .../get-agent-session-execution-namespace.ts | 8 ++ .../agent-session-resume-ownership.ts | 9 +- .../launch/agent-transcript-presence.test.ts | 37 +++++++ .../launch/agent-transcript-presence.ts | 29 ++++++ .../launch/mobile-terminal-startup.ts | 9 +- ...wake-turnless-claude-fresh-session.test.ts | 96 +++++++++++++++++++ .../agent-launch/startup/agent-resume-argv.ts | 17 ++++ .../tui-agent-resume-fresh-session.test.ts | 42 ++++++++ .../startup/tui-agent-resume-startup.ts | 18 +++- 9 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/terminal/launch/agent-transcript-presence.test.ts create mode 100644 src/main/runtime/terminal/launch/agent-transcript-presence.ts create mode 100644 src/main/runtime/terminal/recovery/wake-turnless-claude-fresh-session.test.ts create mode 100644 src/shared/agent-launch/startup/tui-agent-resume-fresh-session.test.ts diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index f8d82f45512..009243d15f8 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -22,6 +22,7 @@ import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session import { isTerminalLeafId, makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import { resolveSavedRestoreLaunchRecipe } from './saved-restore-launch-recipe' import { worktreeIdsEqual } from '../../../../shared/worktree/id' +import { hasMissingAgentTranscript } from '../../terminal/launch/agent-transcript-presence' import { resolveTuiAgentLaunchRawArgs, resolveTuiAgentLaunchEnv @@ -168,8 +169,15 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O const isRemote = Boolean(workspace.connectionId) const restored = request.resumeOrigin === 'saved' ? this.savedRestoreRecipe(workspace.id, request) : undefined + // A turn-less Claude wrote no transcript: start it fresh under its id, not into "No conversation found". + const freshSession = await hasMissingAgentTranscript( + request.agent, + identity.providerSession, + workspace.connectionId + ) const startup = buildAgentResumeStartupPlan( resolveWslHostAgentResumeInputs({ + freshSession, agent: request.agent, providerSession: identity.providerSession, cmdOverrides: settings.agentCmdOverrides ?? {}, diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts index 1c0b9c3e224..caca7e42195 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts @@ -16,6 +16,7 @@ import type { import type { OrcaRuntimeWithMarkPtyLivenessUnverifiable as PtyExitFloor } from '../../terminal/layout/terminal-input-floor' import type { OrcaRuntimeWithStopTerminalsForWorktree as SessionEditHost } from '../../terminal/stop/worktree-terminal-stop' import { claimSleepRecordForPane, isUnclaimedRunningSleepRecord } from './sleep-record-pane-claim' +import { hasMissingAgentTranscript } from '../../terminal/launch/agent-transcript-presence' import { AgentSessionResumeClaims, type AgentSessionResumeClaim, @@ -128,7 +129,13 @@ export abstract class OrcaRuntimeWithAgentSessionResumeOwnership extends OrcaRun return null } const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) - const resume = this.resolveMobileSessionResumeCommand(workspace, record) + // A turn-less Claude wrote no transcript: a wake relaunch starts it fresh instead of "No conversation found". + const freshSession = await hasMissingAgentTranscript( + record.agent, + record.providerSession, + workspace.connectionId + ) + const resume = this.resolveMobileSessionResumeCommand(workspace, record, { freshSession }) if (!resume) { return null } diff --git a/src/main/runtime/terminal/launch/agent-transcript-presence.test.ts b/src/main/runtime/terminal/launch/agent-transcript-presence.test.ts new file mode 100644 index 00000000000..bc527f9c667 --- /dev/null +++ b/src/main/runtime/terminal/launch/agent-transcript-presence.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { hasMissingAgentTranscript } from './agent-transcript-presence' + +const missing = async (): Promise => false +const present = async (): Promise => true + +describe('hasMissingAgentTranscript', () => { + it('is true only for a local Claude session whose recorded transcript is not on disk', async () => { + await expect( + hasMissingAgentTranscript( + 'claude', + { transcriptPath: '/h/.claude/projects/p/s.jsonl' }, + null, + missing + ) + ).resolves.toBe(true) + await expect( + hasMissingAgentTranscript( + 'claude', + { transcriptPath: '/h/.claude/projects/p/s.jsonl' }, + null, + present + ) + ).resolves.toBe(false) + }) + + it('keeps the resume when nothing proves the transcript missing', async () => { + // No recorded path, another agent, or a session on an SSH host whose files this host cannot see. + await expect(hasMissingAgentTranscript('claude', {}, null, missing)).resolves.toBe(false) + await expect( + hasMissingAgentTranscript('codex', { transcriptPath: '/h/.codex/s.jsonl' }, null, missing) + ).resolves.toBe(false) + await expect( + hasMissingAgentTranscript('claude', { transcriptPath: '/h/s.jsonl' }, 'ssh-1', missing) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/runtime/terminal/launch/agent-transcript-presence.ts b/src/main/runtime/terminal/launch/agent-transcript-presence.ts new file mode 100644 index 00000000000..2b8284ba242 --- /dev/null +++ b/src/main/runtime/terminal/launch/agent-transcript-presence.ts @@ -0,0 +1,29 @@ +import { access } from 'node:fs/promises' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' + +/** Whether a host-built resume should start the session fresh: only when this host can see the agent's files + * (no SSH connection) and the transcript its hooks named is not on disk, i.e. the session never took a turn. + * Anything unproven (no recorded path, another agent, an unreadable folder) keeps the plain resume. */ +export async function hasMissingAgentTranscript( + agent: TuiAgent, + providerSession: Pick, + connectionId: string | null | undefined, + exists: (path: string) => Promise = pathExists +): Promise { + const transcriptPath = providerSession.transcriptPath?.trim() + if (agent !== 'claude' || connectionId || !transcriptPath) { + return false + } + return !(await exists(transcriptPath)) +} + +async function pathExists(path: string): Promise { + try { + await access(path) + return true + } catch (error) { + // Only a missing file proves no transcript; a permission error says nothing about it. + return !(error instanceof Error && 'code' in error && error.code === 'ENOENT') + } +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts index b9d77875ad8..070b24a59b5 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts @@ -66,7 +66,9 @@ export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends /** The `--resume` launch for a pane's recorded agent session, as the host window's cold restore builds it. */ protected resolveMobileSessionResumeCommand( workspace: TerminalWorkspaceLaunchScope, - record: ResumableAgentSessionSource + record: ResumableAgentSessionSource, + /** The host proved the session never wrote a transcript (hasMissingAgentTranscript). */ + options: { freshSession?: boolean } = {} ): MobileTerminalResumeCommand | null { const providerSession = normalizeAgentProviderSession(record.providerSession) if (!this.store || !providerSession || !isResumableTuiAgent(record.agent)) { @@ -97,7 +99,10 @@ export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends isRemote: Boolean(workspace.connectionId), launchCwd: workspace.path }) - const startupPlan = buildAgentResumeStartupPlan(resumeInputs) + const startupPlan = buildAgentResumeStartupPlan({ + ...resumeInputs, + ...(options.freshSession ? { freshSession: true } : {}) + }) if (!startupPlan) { return null } diff --git a/src/main/runtime/terminal/recovery/wake-turnless-claude-fresh-session.test.ts b/src/main/runtime/terminal/recovery/wake-turnless-claude-fresh-session.test.ts new file mode 100644 index 00000000000..69f177a6259 --- /dev/null +++ b/src/main/runtime/terminal/recovery/wake-turnless-claude-fresh-session.test.ts @@ -0,0 +1,96 @@ +// WAKE-1: a Claude pane that never took a turn wrote no transcript, so the host's wake relaunch must not +// `--resume` it (Claude prints "No conversation found" and the pane is left a bare shell). +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload' +import { partialRuntimeStore } from '../../orca-runtime-tests/runtime-store-partial-fixture' + +// Preserve side-effect order: mocks, lifecycle, fixtures. +await import('../../orca-runtime-tests/runtime-test-module-registration.spec') +const { OrcaRuntimeService } = await import('../../orca-runtime') +await import('../../orca-runtime-tests/orca-runtime-test-lifecycle.spec') +const { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} = await import('../../orca-runtime-tests/orca-runtime-test-fixtures.spec') +const { committingSpawn } = await import('../../orca-runtime-tests/bind-first-pane.test-fixture') + +const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` +const SESSION_ID = '81eb0bf5-6f2a-4c8e-9d1b-2a3c4d5e6f70' +const RESUMES = new RegExp(`--resume'? '?${SESSION_ID}`) +const STARTS_FRESH = new RegExp(`--session-id'? '?${SESSION_ID}`) + +let dir = '' +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'orca-wake-transcript-')) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +function claudeRow(transcriptPath: string): AgentStatusIpcPayload { + return { + paneKey: PANE_KEY, + agentType: 'claude', + providerSession: { key: 'session_id', id: SESSION_ID, transcriptPath }, + prompt: '', + state: 'done', + receivedAt: 1, + stateStartedAt: 1, + connectionId: null + } +} + +async function wake(row: AgentStatusIpcPayload): Promise { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const [tab] = session.tabsByWorktree[TEST_WORKTREE_ID] ?? [] + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...session, + tabsByWorktree: { [TEST_WORKTREE_ID]: [{ ...tab!, launchAgent: 'claude' }] } + }) + const spawn = vi.fn( + async (_args: { worktreeId?: string; tabId?: string; leafId?: string; command?: string }) => ({ + id: 'woken-pty' + }) + ) + const runtime = new OrcaRuntimeService(partialRuntimeStore(runtimeStore), undefined, { + getAgentProviderSessionSnapshot: () => [row], + getAgentProviderSessionRowsForPane: (paneKey: string) => (paneKey === row.paneKey ? [row] : []), + checkHookAgentPresence: async () => 'exited' + }) + runtime.setPtyController({ + spawn: committingSpawn(runtime, spawn), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + // The host's own relaunch of the woken pane, which no client asked for. + await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', HEADLESS_LEAF_ID, { + notifyClients: false, + navigation: 'caller', + intent: 'automatic' + }) + return spawn.mock.calls[0]?.[0].command ?? '' +} + +describe('waking a pane whose Claude session is recorded', () => { + it('starts a turn-less session fresh under its id when its transcript is not on disk', async () => { + const command = await wake(claudeRow(join(dir, `${SESSION_ID}.jsonl`))) + expect(command).toMatch(STARTS_FRESH) + expect(command).not.toMatch(RESUMES) + expect(command).not.toContain('--resume') + }) + + it('still resumes the session by id when its transcript exists', async () => { + const transcriptPath = join(dir, `${SESSION_ID}.jsonl`) + await writeFile(transcriptPath, '{"type":"user"}\n') + const command = await wake(claudeRow(transcriptPath)) + expect(command).toMatch(RESUMES) + expect(command).not.toContain('--session-id') + }) +}) diff --git a/src/shared/agent-launch/startup/agent-resume-argv.ts b/src/shared/agent-launch/startup/agent-resume-argv.ts index e788807129f..281b80b6779 100644 --- a/src/shared/agent-launch/startup/agent-resume-argv.ts +++ b/src/shared/agent-launch/startup/agent-resume-argv.ts @@ -90,6 +90,23 @@ export function getAgentResumeArgv( } } +/** A Claude session that never took a turn wrote no transcript, so `--resume` only prints "No conversation + * found". Start it fresh under the same id instead: holds, claims and readers keyed on it still match. */ +export function getAgentFreshSessionArgv( + agent: ResumableTuiAgent, + providerSession: AgentProviderSessionMetadata +): string[] | null { + if (agent !== 'claude' || providerSession.key !== 'session_id') { + return null + } + return [ + 'claude', + ...(providerSession.effort ? ['--effort', providerSession.effort] : []), + '--session-id', + providerSession.id + ] +} + /** CLI fork creates a new conversation ID so chat and terminal cannot write the same one. */ const FORK_ARGV: Partial< Record string[] | null> diff --git a/src/shared/agent-launch/startup/tui-agent-resume-fresh-session.test.ts b/src/shared/agent-launch/startup/tui-agent-resume-fresh-session.test.ts new file mode 100644 index 00000000000..eecc3d01f72 --- /dev/null +++ b/src/shared/agent-launch/startup/tui-agent-resume-fresh-session.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' + +const ID = '81eb0bf5-6f2a-4c8e-9d1b-2a3c4d5e6f70' +const base = { + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: ID }, + cmdOverrides: {}, + platform: 'linux' as const, + agentArgs: '--model opus --effort high' +} + +describe('a fresh start for a session with no transcript', () => { + it('keeps the pane’s recorded args and the session id, without --resume', () => { + const plan = buildAgentResumeStartupPlan({ ...base, freshSession: true }) + expect(plan?.launchCommand).toMatch(/--model'? '?opus'? '?--effort'? '?high/) + expect(plan?.launchCommand).toMatch(new RegExp(`--session-id'? '?${ID}`)) + expect(plan?.launchCommand).not.toContain('--resume') + }) + + it('never sends a continue prompt into a fresh session', () => { + const plan = buildAgentResumeStartupPlan({ + ...base, + freshSession: true, + continuePrompt: 'continue' + }) + expect(plan?.launchCommand).not.toContain('continue') + }) + + it('resumes as before without the flag, and other agents ignore it', () => { + expect(buildAgentResumeStartupPlan(base)?.launchCommand).toMatch( + new RegExp(`--resume'? '?${ID}`) + ) + const codex = buildAgentResumeStartupPlan({ + ...base, + agent: 'codex', + agentArgs: '', + freshSession: true + }) + expect(codex?.launchCommand).toMatch(new RegExp(`resume'? '?${ID}`)) + }) +}) diff --git a/src/shared/agent-launch/startup/tui-agent-resume-startup.ts b/src/shared/agent-launch/startup/tui-agent-resume-startup.ts index a1f61d88365..a9cb74a1d23 100644 --- a/src/shared/agent-launch/startup/tui-agent-resume-startup.ts +++ b/src/shared/agent-launch/startup/tui-agent-resume-startup.ts @@ -1,4 +1,4 @@ -import { getAgentForkArgv, getAgentResumeArgv } from './agent-resume-argv' +import { getAgentForkArgv, getAgentFreshSessionArgv, getAgentResumeArgv } from './agent-resume-argv' import type { AgentProviderSessionMetadata, ResumableTuiAgent @@ -40,10 +40,18 @@ export function buildAgentResumeStartupPlan(args: { /** First message the resumed session submits, e.g. to carry on a turn its host cut off. Only * agents whose resume takes a prompt argument (Claude, Codex) send it; others ignore it. */ continuePrompt?: string | null + /** The host proved the session has no transcript: start it fresh under its id where the agent can. */ + freshSession?: boolean }): AgentStartupPlan | null { - const resumeArgv = args.fork - ? getAgentForkArgv(args.agent, args.providerSession) - : getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath) + const freshArgv = + args.freshSession && !args.fork + ? getAgentFreshSessionArgv(args.agent, args.providerSession) + : null + const resumeArgv = + freshArgv ?? + (args.fork + ? getAgentForkArgv(args.agent, args.providerSession) + : getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)) if (!resumeArgv) { return null } @@ -54,7 +62,7 @@ export function buildAgentResumeStartupPlan(args: { : resumeArgv const continuePrompt = args.continuePrompt?.trim() const argv = - continuePrompt && !args.fork && RESUME_PROMPT_AGENTS.has(args.agent) + continuePrompt && !args.fork && !freshArgv && RESUME_PROMPT_AGENTS.has(args.agent) ? [...withCwd, continuePrompt] : withCwd const shell = resolveStartupShell(args.platform, args.shell)