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 <id>, 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 <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.
This commit is contained in:
Neil
2026-11-11 06:31:37 -08:00
parent 6a68631ae4
commit 07adb39663
9 changed files with 257 additions and 8 deletions
@@ -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 ?? {},
@@ -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
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { hasMissingAgentTranscript } from './agent-transcript-presence'
const missing = async (): Promise<boolean> => false
const present = async (): Promise<boolean> => 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)
})
})
@@ -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<AgentProviderSessionMetadata, 'transcriptPath'>,
connectionId: string | null | undefined,
exists: (path: string) => Promise<boolean> = pathExists
): Promise<boolean> {
const transcriptPath = providerSession.transcriptPath?.trim()
if (agent !== 'claude' || connectionId || !transcriptPath) {
return false
}
return !(await exists(transcriptPath))
}
async function pathExists(path: string): Promise<boolean> {
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')
}
}
@@ -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
}
@@ -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<string> {
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')
})
})
@@ -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<ResumableTuiAgent, (providerSession: AgentProviderSessionMetadata) => string[] | null>
@@ -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}`))
})
})
@@ -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)