mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
+8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user