A chat never starts writing a conversation a terminal is still writing, and restored panes respect open chats (#30930)

* fix(native-chat): one ownership index with link roles, and a chat refuses to start while a terminal writes its conversation

Each chat link now carries its role (head or earlier) and key; Session History names
the chat continuing a conversation first, duplicate adoption checks only head links in
both the pre-commit check and the store transaction, and terminal resumes stay refused
exactly as before. The terminal-writer check now also runs inside acquireOwner before
the spawn, refusing with the typed pre-spawn reason conversationOpenInTerminal.

* fix(native-chat): hold a terminal start Orca issues until its terminal reports it

An in-memory registry of managed terminal starts per conversation, registered in the
same step as the resume admission (terminal.send with a resume command, createTerminal
with a command), bound to the PTY execution that runs it, and ended only when that
terminal's hooks report the conversation or the execution is gone. A chat's agent
start refuses while an entry may hold its conversation.

* fix(terminal): an automatic pane restore never resumes a conversation an open chat continues

Resume recipes built from a pane's saved facts (cold restore, deferred and late reattach,
sleeping wake, host mobile materialization) carry resumeOrigin 'saved'. The execution host
decides before any command is issued: while an open chat continues the conversation, or a
chat may be writing it, the pane starts its plain shell with the started-fresh notice and
its saved claim is forgotten; with unreadable records only a saved restore is refused and
its record kept. An admitted resume registers its managed start in the same step, so the
chat's agent start refuses until the terminal reports it or exits. Clearing the last saved
record now reaches disk.

* Fix saved terminal resume ownership and effective launch results

* Preserve conversation case and clear refused cold restore identity

* fix: publish effective terminal launch results on attachment

* fix: preserve terminal launch callback and candidate ownership

* fix: keep existing terminal attachments observational

* fix: consolidate terminal launch imports

* test: document snapshot runtime fixture boundary

* fix: preserve terminal launch identity types

* fix: accept reattach payload policy sessions

* fix: preserve terminal result validation contracts

* refactor: separate terminal create contract

* test: document partial terminal runtime fixtures

* test: align sleeping resume store fixture

* Fix terminal create type contracts

* fix: return the terminal main started to its waiting view

* fix: register reattached terminals before publishing their handles

* Check unavailable-resume test fixture types

* Complete sidebar resume test store fixture

* test: bound saved resume ownership and refusal work

* Settle refused saved resume claims from actual launch results

* Fix saved resume test type contracts

* test(e2e): capture exact terminal parking diagnostics

* test: verify parked terminal with fresh host inventory

* Consolidate agent session imports in remote terminal transport

* Clarify terminal conversation refusal guidance

* Admit note-only saved conversations without hiding live writers

* Retain terminal writer proofs through final admission
This commit is contained in:
Brennan Benson
2026-10-25 14:39:05 -07:00
committed by GitHub
parent 27e7f8fd6d
commit 883b768bc7
178 changed files with 6034 additions and 537 deletions
@@ -16,6 +16,11 @@ on:
- '!config/scripts/renderer-scrollbar-style-plugin.test.mjs'
# Keep in sync with the contract list in config/vitest.performance.config.ts;
# without these a rename lands green and only breaks the next nightly.
- 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts'
- 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts'
- 'src/main/runtime/hook-status-resume-sleep-ordering.test.ts'
- 'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts'
- 'src/main/sqlite/sync-database.test.ts'
- 'src/main/runtime/orchestration/db/row-column-lists.test.ts'
- 'src/shared/native-chat/tools/native-chat-tool-attribution-allocation.test.ts'
@@ -156,6 +156,7 @@ const NODE_RUNTIME_INCLUDE = [
'src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts',
'src/renderer/src/store/github/cache-persistence.test.ts',
'src/main/native-chat/agent-session-journal/**/*.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts',
+5
View File
@@ -5,6 +5,11 @@ import baseConfig from './vitest.config'
import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool'
const contracts = [
'src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts',
'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts',
'src/main/runtime/hook-status-resume-sleep-ordering.test.ts',
'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts',
'src/shared/tui-agent-permission-choice.test.ts',
'src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx',
'src/main/runtime/rpc/methods/repo.test.ts',
@@ -48,6 +48,22 @@ const startRow: AgentJournalRenderItem = {
}
describe("the phone's line under a not-sent message", () => {
it.each([
['claude', 'Claude'],
['codex', 'Codex']
])('names %s when its terminal holds the conversation', (agent, name) => {
const notices = mobileNativeChatUnsentNotices(
{
items: [],
submissions: [submission('held', { rejection: { kind: 'conversationOpenInTerminal' } })]
},
agent
)
expect(notices.get(agentJournalSubmissionKey('held'))).toBe(
`${name} is already running this conversation in another terminal. Quit ${name} there or close that terminal to continue here.`
)
})
it("words each rejected message as the desktop does, naming the chat's agent", () => {
const notices = mobileNativeChatUnsentNotices(
{
@@ -3,8 +3,38 @@ import { paneCacheKeyMatchesTab } from './server-status-identity'
import { AgentHookServerCleanup } from './server-cleanup'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { releaseAgentExitKey } from './agent-exit-evidence'
import type { RefusedPaneResume } from '../../../shared/agent-status-types'
import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume'
export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
/** The host refused `paneKey`'s saved resume because a native chat is its conversation's home:
* forget the pane's claim here, with no resumable remnant, and tell renderers to forget theirs. */
forgetRefusedPaneResume(paneKey: string, refused: RefusedPaneResume): void {
// A moved pane's row, and the renderer's state for it, live under its owner's key.
const ownerKey = this.resolvePaneKeyAlias(paneKey)
const row = this.state.lastStatusByPaneKey.get(ownerKey)
const clear = { paneKey: ownerKey, refusedResume: refused }
if (
row &&
'receivedAt' in row &&
typeof row.receivedAt === 'number' &&
'stateStartedAt' in row &&
typeof row.stateStartedAt === 'number' &&
refused.expectedStatus &&
row.receivedAt <= refused.expectedStatus.receivedAt &&
row.stateStartedAt === refused.expectedStatus.stateStartedAt &&
row.payload.agentType === refused.agent &&
agentProviderSessionsEqual(refused.agent, row.providerSession, refused.providerSession)
) {
this.dropStatusEntry(ownerKey, { preserveResumeIdentity: false })
this.emitPaneStatusCleared(clear)
return
}
// Renderers only: the pane's row is another conversation's, which other subscribers keep.
this.onPaneStatusCleared?.(clear)
}
/** Drop every status/cache claim attributable to a closed tab prefix. */
dropStatusEntriesByTabPrefix(tabId: string): void {
this.markTabClosedForAgentStatus(tabId)
this.clearTmuxTabSubjects(tabId)
@@ -16,12 +16,12 @@ import {
export const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
export function installOwnership(
overrides: Partial<StructuredProviderSessionOwnership> & {
overrides: Partial<Omit<StructuredProviderSessionOwnership, 'role' | 'key'>> & {
handle?: AgentSessionProviderHandle
} = {}
): void {
const { handle, ...ownershipOverrides } = overrides
const ownership: StructuredProviderSessionOwnership = {
const ownership: Omit<StructuredProviderSessionOwnership, 'role' | 'key'> = {
sessionId: 'session-alpha',
workspaceId: 'workspace-1',
provider: handle?.agent ?? 'codex',
@@ -86,6 +86,28 @@ export function installCopyingChat(
} as never)
}
/** A live Codex chat whose chain held `threadIds` in order; the last is its head. */
export function codexChatHolding(sessionId: string, threadIds: string[]): AgentSessionRecord {
const lease = { ...agentSessionLeaseFixture(), sessionId }
const fixture = agentSessionRecordFixture(lease)
return {
...fixture,
sessionId,
provider: 'codex',
providerHandleChain: threadIds.map((threadId, index) => ({
...fixture.providerHandleChain[0]!,
linkId: `${sessionId}-${index}`,
handle: codexProviderHandle(threadId)
})),
lease
}
}
export function installChats(records: AgentSessionRecord[]): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached.
setStructuredAgentSessionHost({ deps: { store: { listRecords: () => records } } } as never)
}
export function listResult(): AiVaultListResult {
const session: AiVaultSession = {
id: `local:codex:${PROVIDER_SESSION}`,
@@ -4,12 +4,15 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire
import {
assertLegacyAiVaultResumeAllowed,
assertLegacyAiVaultResumeCommandAllowed,
findResumeOwnership,
OPENS_EVERY_STRUCTURED_CHAT,
projectStructuredAiVaultSearchResponse,
projectStructuredAiVaultSessions
} from './structured-session-ownership'
import type { AiVaultSearchHit, AiVaultSearchResponse } from '../../shared/ai-vault-search-types'
import {
codexChatHolding,
installChats,
installOwnership,
listResult,
PROVIDER_SESSION
@@ -107,6 +110,39 @@ describe('structured AI Vault ownership', () => {
}
)
it('names the chat continuing a conversation first, while every link still refuses a terminal', async () => {
const movedOn = codexChatHolding('session-moved', [PROVIDER_SESSION, OTHER_SESSION])
const owned = (records: ReturnType<typeof codexChatHolding>[]) => {
installChats(records)
return projectStructuredAiVaultSessions(listResult(), OPENS_EVERY_STRUCTURED_CHAT).sessions[0]
?.structuredSession?.sessionId
}
expect(owned([movedOn, codexChatHolding('session-now', [PROVIDER_SESSION])])).toBe(
'session-now'
)
expect(owned([movedOn])).toBe('session-moved')
expect(
findResumeOwnership({ agent: 'codex', sessionId: PROVIDER_SESSION, filePath: '' })
).toMatchObject({
sessionId: 'session-moved',
role: 'earlier'
})
expect(() =>
assertLegacyAiVaultResumeAllowed({
agent: 'codex',
filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`,
codexHome: null,
executionHostId: 'local'
})
).toThrow('agent_session_conflict')
await expect(
assertLegacyAiVaultResumeCommandAllowed(
`codex resume ${PROVIDER_SESSION}`,
async () => undefined
)
).rejects.toThrow('agent_session_conflict')
})
it('derives typed refusals from the single writer predicate for live and proving leases', async () => {
installOwnership()
expect(() =>
@@ -9,6 +9,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire
import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal'
import {
listStructuredProviderSessionOwnership,
terminalResumeBlocked,
type StructuredProviderSessionOwnership
} from '../native-chat/agent-session-wire/structured-provider-session-ownership'
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
@@ -122,18 +123,16 @@ function ownershipLookup():
const byProviderSession = new Map<string, StructuredProviderSessionOwnership>()
for (const ownership of listOwnership()) {
const key = `${ownership.provider}\0${ownership.providerSessionId}`
if (!byProviderSession.has(key)) {
const kept = byProviderSession.get(key)
if (!kept || (kept.role !== 'head' && ownership.role === 'head')) {
byProviderSession.set(key, ownership)
}
}
return (row) => {
for (const owner of historyRowOwners(row.agent)) {
const ownership = byProviderSession.get(`${owner}\0${row.sessionId}`)
if (ownership) {
return ownership
}
}
return null
const found = historyRowOwners(row.agent).flatMap(
(owner) => byProviderSession.get(`${owner}\0${row.sessionId}`) ?? []
)
return found.find((ownership) => ownership.role === 'head') ?? found[0] ?? null
}
}
@@ -154,9 +153,9 @@ function historyRowOwners(rowAgent: string): readonly StructuredAgentId[] {
export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void {
// A fork writes a new conversation, and preparing its home never writes this one.
const ownership = args.fork ? null : findResumeOwnership(args)
if (ownership) {
refuseLegacyWriter(ownership)
const blocking = args.fork ? undefined : resumeOwnerships(args).find(terminalResumeBlocked)
if (blocking) {
refuseLegacyWriter(blocking)
}
}
@@ -169,12 +168,11 @@ export async function assertLegacyAiVaultResumeCommandAllowed(
return
}
await ensureStructuredAgentSessionHostUnlessRefused(ensureHost)
const host = getStructuredAgentSessionHost()
if (!host) {
return
}
for (const ownership of listOwnership()) {
if (invocations.some((invocation) => resumesOwnedSession(invocation, ownership))) {
if (
terminalResumeBlocked(ownership) &&
invocations.some((invocation) => resumesOwnedSession(invocation, ownership))
) {
refuseLegacyWriter(ownership)
}
}
@@ -183,19 +181,25 @@ export async function assertLegacyAiVaultResumeCommandAllowed(
export function findResumeOwnership(
args: Pick<AiVaultPrepareSessionResumeArgs, 'agent' | 'sessionId' | 'filePath'>
): StructuredProviderSessionOwnership | null {
return resumeOwnerships(args)[0] ?? null
}
/** Every link naming the row, those matched by id first. */
function resumeOwnerships(
args: Pick<AiVaultPrepareSessionResumeArgs, 'agent' | 'sessionId' | 'filePath'>
): StructuredProviderSessionOwnership[] {
const owners = new Set(historyRowOwners(args.agent))
if (owners.size === 0) {
return null
return []
}
const owned = listOwnership().filter((ownership) => owners.has(ownership.provider))
const fileName = args.filePath.split(/[\\/]/).at(-1) ?? ''
return (
(args.sessionId
? owned.find((ownership) => ownership.providerSessionId === args.sessionId)
: undefined) ??
owned.find((ownership) => fileName.includes(ownership.providerSessionId)) ??
null
)
return [
...(args.sessionId
? owned.filter((ownership) => ownership.providerSessionId === args.sessionId)
: []),
...owned.filter((ownership) => fileName.includes(ownership.providerSessionId))
]
}
function listOwnership(): StructuredProviderSessionOwnership[] {
@@ -1,3 +1,4 @@
import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
import { describe, expect, it, vi } from 'vitest'
import { CodexAppServerFrameSizeError } from './app-server/codex-app-server-frame-size-error'
import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error'
@@ -316,3 +317,43 @@ describe('openCodexThread', () => {
})
})
})
it('keeps an exact active-writer refusal typed through cleanup wrapping', async () => {
const error = new CodexAppServerRequestError(
'thread/resume',
-32600,
'thread C already has an active writer'
)
const request = vi.fn().mockRejectedValue(error)
let refused: unknown
try {
await openCodexThread(connectionFor(request), { cwd: '/workspace', resumeThreadId: 'C' }, 2_000)
} catch (caught) {
refused = caught
}
expect(refused).toMatchObject({ reason: 'conversationOpenElsewhere' })
expect(providerStartupFailureFact(new AggregateError([refused], 'cleanup failed'))).toMatchObject(
{
kind: 'conversationOpenElsewhere',
detail: { text: error.message, audience: 'log' }
}
)
expect(request).toHaveBeenCalledOnce()
})
it.each([
['thread/resume', -32600, 'thread D already has an active writer'],
['thread/start', -32600, 'thread C already has an active writer'],
['thread/resume', -32600, 'thread C is archived'],
['thread/resume', -32600, 'unrelated invalid request'],
['thread/resume', -1, 'thread C already has an active writer']
] as const)('does not broaden writer refusal for %s/%s/%s', async (method, code, message) => {
const error = new CodexAppServerRequestError(method, code, message)
await expect(
openCodexThread(
connectionFor(vi.fn().mockRejectedValue(error)),
{ cwd: '/workspace', resumeThreadId: 'C' },
2_000
)
).rejects.toBe(error)
})
@@ -1,3 +1,5 @@
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
@@ -88,6 +90,21 @@ export async function openCodexThread(
)
} catch (error) {
throwIfSignalAborted(signal)
if (
isCodexAppServerRequestError(error) &&
error.method === 'thread/resume' &&
error.code === -32600 &&
/\bthread\s+(\S+)\s+already has an active writer\b/i.exec(error.message)?.[1] ===
resumeThreadId
) {
throw withProviderDiagnostic(
Object.assign(
new AgentSessionAcquisitionRefusal(error.message, 'conversationOpenElsewhere'),
{ cause: error }
),
providerDiagnostic(error.providerDiagnostic?.text ?? error.message, 'log')
)
}
if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) {
throw error
}
@@ -103,6 +103,7 @@ describe('a window view of a pane main is starting', () => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_main'),
getAgentProviderSessionRows: vi.fn(() => []),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
+11
View File
@@ -30,6 +30,7 @@ export const openCodeClearPtyMock: Mock = vi.fn()
export const buildAgentHookEnvMock: Mock = vi.fn()
export const clearAgentHookPaneStateMock: Mock = vi.fn()
export const registerPaneKeyAliasMock: Mock = vi.fn()
export const forgetRefusedPaneResumeMock: Mock = vi.fn()
export const piBuildPtyEnvMock: Mock = vi.fn()
export const piClearPtyMock: Mock = vi.fn()
export const trackMock: Mock = vi.fn()
@@ -127,11 +128,21 @@ export const mimoHookServiceModuleMock = () => ({
}
})
/** What a spawn's resume admission reads from a fake runtime: no chats, so every resume runs. */
export const noChatsRuntimeMembers = () => ({
openAgentSessionRecordStore: vi.fn(async () => ({
listRecords: () => [],
getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] })
}))
})
export const agentHookServerModuleMock = () => ({
agentHookServer: {
buildPtyEnv: buildAgentHookEnvMock,
clearPaneState: clearAgentHookPaneStateMock,
registerPaneKeyAlias: registerPaneKeyAliasMock,
forgetRefusedPaneResume: forgetRefusedPaneResumeMock,
recordLaunchResumeIdentity: () => {},
clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock
}
})
@@ -28,6 +28,7 @@ import {
buildAgentHookEnvMock,
clearAgentHookPaneStateMock,
registerPaneKeyAliasMock,
forgetRefusedPaneResumeMock,
piBuildPtyEnvMock,
piClearPtyMock,
isPwshAvailableMock,
@@ -148,6 +149,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment {
buildAgentHookEnvMock.mockReset()
clearAgentHookPaneStateMock.mockReset()
registerPaneKeyAliasMock.mockReset()
forgetRefusedPaneResumeMock.mockReset()
piBuildPtyEnvMock.mockReset()
piClearPtyMock.mockReset()
isPwshAvailableMock.mockReset()
@@ -0,0 +1,183 @@
import { describe, expect, vi } from 'vitest'
import { forgetRefusedPaneResumeMock, spawnMock } from './pty-ipc-mock-registry'
import { posixOnlyIt } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command'
import { registerPtyHandlers } from './pty/register-handlers'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
vi.mock('../wsl', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
// #26417: a pane that saved a chat's conversation must not resume it while the chat is its home.
describe('a pane restore of a conversation a native chat continues', () => {
const { handlers, mainWindow, createMockProc } = setupPtyIpcSuite()
const chat = agentSessionRecordFixture(
agentSessionLeaseFixture({
claimStatus: 'released',
ownerProcess: null,
reservedSpawnToken: null,
deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
})
)
const C = 'provider-session-alpha-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
function register(openTabs: string[] | 'unreadable') {
const runtime = new OrcaRuntimeService()
vi.spyOn(runtime, 'openAgentSessionRecordStore').mockImplementation(async () => {
if (openTabs === 'unreadable') {
throw new Error('records unavailable')
}
const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
store.listRecords = () => [chat]
store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: openTabs })
return store
})
registerPtyHandlers(mainWindow, runtime)
return runtime
}
async function spawnResume(resumeOrigin?: 'saved', leafId = LEAF_ID) {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
vi.useFakeTimers()
try {
const result = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
tabId: 'tab-floating',
leafId,
command: `claude --resume '${C}'`,
launchAgent: 'claude',
resumeProviderSession: { key: 'session_id', id: C },
...(resumeOrigin ? { resumeOrigin } : {})
})
for (let flush = 0; flush < 3; flush += 1) {
await Promise.resolve()
vi.runAllTimers()
}
const env: Record<string, string> = spawnMock.mock.calls.at(-1)![2].env
const typed = mockProc.proc.write.mock.calls.map((call) => String(call[0]))
const reported = typeof result === 'object' && result !== null ? result : {}
return {
refused: 'agentResumeRefused' in reported ? reported.agentResumeRefused : undefined,
notice: 'agentResumeUnavailable' in reported,
input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n')
}
} finally {
vi.useRealTimers()
}
}
posixOnlyIt(
'starts the plain shell with the notice and forgets the pane claim while the chat tab is open',
async () => {
register([chat.sessionId])
const restored = await spawnResume('saved')
expect(restored).toMatchObject({ refused: 'chat-home', notice: true })
expect(restored.input).not.toContain(C)
expect(forgetRefusedPaneResumeMock).toHaveBeenCalledWith(`tab-floating:${LEAF_ID}`, {
agent: 'claude',
providerSession: { key: 'session_id', id: C }
})
}
)
posixOnlyIt('keeps a committed refused shell usable when status cleanup fails', async () => {
register([chat.sessionId])
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
forgetRefusedPaneResumeMock.mockImplementationOnce(() => {
throw new Error('cleanup failed')
})
try {
const restored = await spawnResume('saved')
expect(restored).toMatchObject({ refused: 'chat-home', notice: true })
expect(restored.input).not.toContain(C)
expect(warning).toHaveBeenCalled()
} finally {
warning.mockRestore()
}
})
posixOnlyIt(
'resumes once the chat tab is closed, with the accepted pre-report interval',
async () => {
const runtime = register([])
const restored = await spawnResume('saved')
expect(restored.refused).toBeUndefined()
expect(restored.input).toContain(`--resume '${C}'`)
await expect(runtime['assertStructuredChatAcquireAdmissible'](chat)).resolves.toBeUndefined()
}
)
posixOnlyIt(
'with unreadable records refuses only a saved restore, and keeps its record',
async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
register('unreadable')
const restored = await spawnResume('saved')
const asked = await spawnResume(undefined, '22222222-2222-4222-8222-222222222222')
expect(restored.refused).toBe('unverifiable')
expect(restored.input).not.toContain(C)
expect(asked.refused).toBeUndefined()
expect(asked.input).toContain(`--resume '${C}'`)
expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled()
}
)
posixOnlyIt('leaves an explicit resume of a chat at rest alone', async () => {
register([chat.sessionId])
const asked = await spawnResume()
expect(asked.refused).toBeUndefined()
expect(asked.input).toContain(`--resume '${C}'`)
})
})
@@ -0,0 +1,332 @@
import type { TuiAgent } from '../../../../shared/tui-agent'
import {
normalizeAgentProviderSession,
agentProviderSessionsEqual,
type AgentProviderSessionMetadata,
type SleepingAgentLaunchConfig,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import type {
AgentStatusIpcPayload,
RefusedPaneResume
} from '../../../../shared/agent-status-types'
import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store'
import {
agentSessionLeasePossiblyWriting,
listStructuredProviderSessionOwnership
} from '../../../native-chat/agent-session-wire/structured-provider-session-ownership'
import { agentHookServer } from '../../../agent-hooks/server'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import {
keysMayMatch,
terminalConversationKey
} from '../../../native-chat/structured-conversation-key'
export type ResumeSpawnArgs = {
connectionId?: string | null
command?: string
launchAgent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
launchToken?: unknown
resumeProviderSession?: AgentProviderSessionMetadata
/** `saved`: the resume was built from a pane's saved facts, not asked for by the user. */
resumeOrigin?: unknown
env?: Record<string, string>
}
type AdmissionRuntime = {
getAgentProviderSessionRows?(): AgentStatusIpcPayload[]
readSavedResumeSource?(
worktreeId: string,
paneKey: string
): Promise<SleepingAgentSessionRecord | undefined>
openAgentSessionRecordStore(): Promise<AgentSessionRecordStore>
}
/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`:
* any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */
export type ResumeSpawnRefusal = {
sourceRecord?: SleepingAgentSessionRecord
verdict: 'chat-home' | 'chat-writing' | 'unverifiable'
refused: RefusedPaneResume
}
export type ResumeSpawnAdmission =
| { verdict: 'admitted'; refused: RefusedPaneResume; sourceRecord?: SleepingAgentSessionRecord }
| ResumeSpawnRefusal
/** Saved-home policy only; ordinary terminal starts remain hook-observed. */
export async function admitResumeSpawn(
runtime: AdmissionRuntime | undefined,
args: ResumeSpawnArgs,
wslDistro: string | null
): Promise<ResumeSpawnAdmission | null> {
const providerSession = normalizeAgentProviderSession(args.resumeProviderSession)
const agent = args.launchAgent
// Native chats run only natively, so a remote or WSL conversation is never a chat's.
if (
args.resumeOrigin !== 'saved' ||
!providerSession ||
!agent ||
args.connectionId ||
wslDistro !== null
) {
return null
}
if (!runtime) {
return { verdict: 'unverifiable', refused: { agent, providerSession } }
}
return judgeResumes(runtime, [{ agent, providerSession }])
}
/** A reattach that found its session gone returns a fresh shell its window then resumes from the
* pane's saved facts; this decides that resume before the window can deliver it. */
export async function admitColdRestoreReattach(
runtime:
| (AdmissionRuntime & { getAgentProviderSessionRows(): AgentStatusIpcPayload[] })
| undefined,
result: { coldRestore?: unknown; isReattach?: boolean },
pane: { paneKey: string | null; saved: SleepingAgentSessionRecord | undefined },
wslDistro: string | null
): Promise<ResumeSpawnAdmission | null> {
const { paneKey, saved } = pane
if (!runtime || !result.coldRestore || result.isReattach || !paneKey || wslDistro !== null) {
return null
}
const resumes = [
...runtime
.getAgentProviderSessionRows()
.flatMap((row) =>
row.paneKey === paneKey &&
row.state !== 'done' &&
!row.structuredHost &&
!row.connectionId &&
row.agentType &&
row.providerSession
? [{ agent: row.agentType, providerSession: row.providerSession }]
: []
),
...(saved && !saved.connectionId
? [{ agent: saved.agent, providerSession: saved.providerSession }]
: [])
]
const [first] = resumes
return first ? judgeResumes(runtime, [first]) : null
}
async function judgeResumes(
runtime: AdmissionRuntime,
resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]]
): Promise<ResumeSpawnAdmission> {
const keysOf = ({ agent, providerSession }: RefusedPaneResume) =>
[providerSession.id, providerSession.transcriptPath].flatMap((id) =>
id?.trim() ? [terminalConversationKey(agent, id)] : []
)
const admitted = (): ResumeSpawnAdmission => ({
verdict: 'admitted',
refused: resumes[0]
})
let store: AgentSessionRecordStore
try {
store = await runtime.openAgentSessionRecordStore()
} catch (error) {
console.warn(
"[pty] native chat records unreadable; cannot tell whether a resume is a chat's",
error
)
return { verdict: 'unverifiable', refused: resumes[0] }
}
const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter(
(holding) => holding.role === 'head'
)
const tabs = store.getVisibleSessionTabIndex()
for (const refused of resumes) {
const holders = heads.filter((holding) =>
keysOf(refused).some((key) => keysMayMatch(holding.key, key))
)
if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) {
return { verdict: 'chat-writing', refused }
}
if (holders.length > 0 && (!tabs || !tabs.present)) {
return { verdict: 'unverifiable', refused }
}
if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) {
return { verdict: 'chat-home', refused }
}
}
return admitted()
}
/** A refused resume becomes the pane's plain shell: nothing of the agent launch is left to run,
* register, or mark the shell as an unattended agent's. */
export function dropAgentLaunch(args: ResumeSpawnArgs): void {
const agentEnv = args.launchConfig?.agentEnv ?? {}
if (args.env) {
args.env = Object.fromEntries(
Object.entries(args.env).filter(
([key, value]) =>
key !== 'ORCA_AGENT_LAUNCH_TOKEN' &&
key !== 'ORCA_SEQUENCED_STARTUP_COMMAND' &&
key !== 'ORCA_SEQUENCED_STARTUP_SCRIPT' &&
agentEnv[key] !== value
)
)
}
delete args.command
delete args.launchAgent
delete args.launchConfig
delete args.launchToken
delete args.resumeProviderSession
}
export function resumeSpawnRefusal(
admission: ResumeSpawnAdmission | null
): ResumeSpawnRefusal | null {
return admission && admission.verdict !== 'admitted' ? admission : null
}
/** Unwinds only the claim whose fresh callback has issued no physical work. */
export class SavedResumeFreshRefusal extends Error {
constructor(readonly admission: ResumeSpawnRefusal) {
super('saved_resume_fresh_refused')
}
}
/** Called only after a refused plain shell exists; uncertain evidence retains its recipe. */
export function forgetRefusedResume(
paneKey: string | null,
refusal: ResumeSpawnRefusal | null
): void {
if (!paneKey || !refusal || refusal.verdict === 'unverifiable') {
return
}
try {
agentHookServer.forgetRefusedPaneResume(paneKey, refusal.refused)
} catch (error) {
// The shell still starts, and the next restore refuses and retries the cleanup.
console.warn('[pty] could not forget a resume a native chat holds', error)
}
}
/** The pane's saved resume on this host, which its window resumes from after a cold restore. */
export function savedResumeRecord(
store:
| { getWorkspaceSession?(hostId: ExecutionHostId): WorkspaceSessionState | undefined }
| undefined,
paneKey: string | null
): SleepingAgentSessionRecord | undefined {
return paneKey
? store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.sleepingAgentSessionsByPaneKey?.[
paneKey
]
: undefined
}
/** Runs only in the fresh branch, after stable/claimed owner adoption has settled. */
export async function chooseSavedResumeFreshLaunch(
runtime: AdmissionRuntime | undefined,
args: ResumeSpawnArgs,
wslDistro: string | null,
options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown }
): Promise<ResumeSpawnAdmission | null> {
const admission = await admitResumeSpawn(runtime, args, wslDistro)
if (resumeSpawnRefusal(admission)) {
suppressSavedResumeLaunch(args, options)
}
return admission
}
/** A claim joiner uses the same suppression without reclassifying the settled request. */
export function suppressSavedResumeLaunch(
args: ResumeSpawnArgs,
options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown }
): void {
options.launchConfig = args.launchConfig
dropAgentLaunch(options)
delete options.startupCommandDelivery
delete options.startupIngress
dropAgentLaunch(args)
}
export function savedResumeResult(admission: ResumeSpawnAdmission | null) {
if (!admission) {
return {}
}
const { agent, providerSession } = admission.refused
return {
savedResumeDecision: { agent, providerSession, verdict: admission.verdict },
...(admission.verdict !== 'admitted'
? {
agentResumeRefused: admission.verdict,
agentResumeUnavailable: true as const,
refusedResume: admission.refused
}
: {})
}
}
/** Capture before spawn; later same-conversation status and saved versions belong to later work. */
export async function captureSavedResumeCleanup(
runtime: AdmissionRuntime | undefined,
store: Parameters<typeof savedResumeRecord>[0],
paneKey: string | null,
admission: ResumeSpawnAdmission | null,
worktreeId?: string
): Promise<void> {
if (!paneKey || !admission) {
return
}
let candidate = savedResumeRecord(store, paneKey)
if (runtime?.readSavedResumeSource && worktreeId) {
try {
candidate = await runtime.readSavedResumeSource(worktreeId, paneKey)
} catch (error) {
console.warn('[pty] could not capture saved resume cleanup source', error)
}
}
const saved =
candidate &&
candidate.agent === admission.refused.agent &&
agentProviderSessionsEqual(
candidate.agent,
candidate.providerSession,
admission.refused.providerSession
)
? candidate
: undefined
const row = runtime
?.getAgentProviderSessionRows?.()
.find(
(entry) =>
entry.paneKey === paneKey &&
entry.agentType === admission.refused.agent &&
agentProviderSessionsEqual(
entry.agentType,
entry.providerSession,
admission.refused.providerSession
)
)
admission.sourceRecord = saved
admission.refused = {
...admission.refused,
...(row
? {
expectedStatus: {
paneKey,
receivedAt: row.receivedAt,
stateStartedAt: row.stateStartedAt
}
}
: {}),
...(saved
? {
expectedSavedRecord: {
paneKey,
capturedAt: saved.capturedAt,
updatedAt: saved.updatedAt
}
}
: {})
}
}
+3 -5
View File
@@ -5,7 +5,6 @@ import { resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launc
import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict'
import { agentLaunchPaneLiveness } from '../pane/agent-launch-pane-evidence'
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry'
import { createPtySpawnTiming } from '../pty-spawn-timing'
import { allocatePtyLifecycleSequence } from '../host-env/types'
@@ -13,7 +12,8 @@ import { snapshotCodexPaneHomeRoutes } from '../host-env/codex-home'
import { getAppPtyId } from '../provider/registry'
import {
makePaneSpawnReservationKey,
pendingRuntimePaneCreatesByOwnerKey
pendingRuntimePaneCreatesByOwnerKey,
type PaneSpawnReservationResult
} from '../pane/spawn-reservation'
import { resolveStablePaneOwner } from '../pane/stable-owner'
import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner'
@@ -38,7 +38,7 @@ export function resolveEarlyPaneSpawnReservationKey(args: PtySpawnIpcArgs): stri
export async function beginPtyIpcSpawn(
ctx: PtyIpcSpawnState
): Promise<PtySpawnResult | { isReattach: true } | null> {
): Promise<PaneSpawnReservationResult | void> {
const args = ctx.args
// Launch panes adopt their agent instead of starting a shell; explicit restarts already own the reservation.
const launchPaneKey = ctx.paneSpawnReservation ? null : resolveEarlyPaneKey(args)
@@ -147,6 +147,4 @@ export async function beginPtyIpcSpawn(
// Bytes arriving during spawn can postdate proven flags; do not let old flags erase their live negotiation.
ctx.snapshotKittyFlagsCoverReconciledSeq = true
ctx.preparedProvisionalExecutionContext = false
return null
}
@@ -56,11 +56,15 @@ async function commit(
ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) }
ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID }
prepare?.(ctx)
let reply: Awaited<ReturnType<typeof commitPtyIpcSpawn>> | undefined
const outcome = await commitPtyIpcSpawn(ctx).then(
() => 'committed',
(result) => {
reply = result
return 'committed'
},
() => 'discarded'
)
return { outcome, facts, stops }
return { outcome, facts, stops, reply }
}
describe('renderer spawn commit: run facts', () => {
@@ -78,6 +82,36 @@ describe('renderer spawn commit: run facts', () => {
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([])
})
it.each([false, true])(
'keeps a refused shell disposition when early exit is %s',
async (earlyExit) => {
const refused = {
agent: 'claude' as const,
providerSession: { key: 'session_id' as const, id: 'chat-held' }
}
const { outcome, reply } = await commit(vi.fn().mockResolvedValue(true), (ctx) => {
ctx.resumeAdmission = { verdict: 'chat-home', refused }
if (earlyExit) {
ctx.metadataLeafId = 'leaf-1'
ctx.deps.runtime!.getPtyLivenessVerdict = vi.fn(() => ({ status: 'exited' as const }))
ctx.deps.store!.runDurableMutation = vi.fn().mockResolvedValue(true)
ctx.deps.runtime!.registerPty = vi.fn(() => {
throw new Error('agent_session_exited_during_start')
})
}
})
expect(outcome).toBe('committed')
expect(reply).toMatchObject({
id: PTY_ID,
incarnationId: INCARNATION_ID,
agentResumeUnavailable: true,
agentResumeRefused: 'chat-home',
refusedResume: refused,
savedResumeDecision: { ...refused, verdict: 'chat-home' }
})
}
)
it('records nothing for a spawn discarded because its binding save failed', async () => {
const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full'))
const { outcome, facts, stops } = await commit(persistPtyBinding)
+29 -4
View File
@@ -7,6 +7,7 @@ import type { PtySpawnPaneBinding } from '../spawn-commit-binding'
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-placement'
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
import { resumeSpawnRefusal, savedResumeResult } from '../host-env/saved-resume-admission'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
return commitPtyWithOpenCodePromptIntent(ctx, () => {
@@ -97,11 +98,22 @@ function ipcSpawnPaneBinding(ctx: PtyIpcSpawnState): PtySpawnPaneBinding | undef
}
function ipcSpawnResponse(ctx: PtyIpcSpawnState) {
const args = ctx.args
const { startupCwdSource: _startupCwdSource, ...providerResult } = ctx.result
const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result)
return {
...providerResult,
// Kitty flags and their proven sequence boundary must be published together.
...(!ctx.result.isReattach && !resumeSpawnRefusal(ctx.resumeAdmission)
? {
launchAgent: args.launchAgent,
launchToken: typeof args.launchToken === 'string' ? args.launchToken : undefined,
resumeProviderSession: ctx.codexResumeLaunch.droppedResumeArgv
? undefined
: args.resumeProviderSession
}
: {}),
// Why both or neither: a pane can only adopt proven kitty flags together
// with the sequence boundary they describe.
...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' &&
ctx.reconciledSnapshotSeq !== null &&
ctx.snapshotKittyFlagsCoverReconciledSeq
@@ -111,9 +123,22 @@ function ipcSpawnResponse(ctx: PtyIpcSpawnState) {
? { launchConfig: ctx.effectiveLaunchConfig }
: {}),
...(startupCwdFallback ? { startupCwdFallback } : {}),
// Only a fresh launch can report dropped resume; reattach never ran this command.
...(ctx.codexResumeLaunch.notifyResumeUnavailable && !ctx.result.isReattach
...resumeDisposition(ctx)
}
}
/** What became of a requested resume, on every result the renderer receives. */
function resumeDisposition(ctx: PtyIpcSpawnState) {
// Why: the pane asked to resume and got a fresh session instead; only the
// renderer can say so, and a reattach never ran this launch command.
if (ctx.result.isReattach) {
return {}
}
const refusal = resumeSpawnRefusal(ctx.resumeAdmission)
return {
...(ctx.codexResumeLaunch.notifyResumeUnavailable || refusal
? { agentResumeUnavailable: true as const }
: {})
: {}),
...savedResumeResult(ctx.resumeAdmission)
}
}
+6
View File
@@ -32,6 +32,12 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
mark: (phase) => ctx.spawnTiming.mark(phase)
})
ctx.codexResumeLaunch = hostEnv.codexResumeLaunch
if (hostEnv.codexResumeLaunch.droppedResumeArgv && ctx.effectiveLaunchConfig) {
ctx.effectiveLaunchConfig = {
...ctx.effectiveLaunchConfig,
agentCommand: hostEnv.launchCommand ?? ctx.effectiveLaunchConfig.agentCommand
}
}
ctx.launchCommand = hostEnv.launchCommand
ctx.baseEnv = hostEnv.baseEnv
ctx.env = hostEnv.env
+55 -3
View File
@@ -1,3 +1,9 @@
import {
captureSavedResumeCleanup,
chooseSavedResumeFreshLaunch,
type ResumeSpawnAdmission,
resumeSpawnRefusal
} from '../host-env/saved-resume-admission'
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach'
import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate'
import { resolveStablePaneOwner } from '../pane/stable-owner'
@@ -14,6 +20,42 @@ import type { PtyIpcSpawnState } from './spawn-state'
export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
const args = ctx.args
const captured: ResumeSpawnAdmission | null =
args.launchAgent && args.resumeProviderSession
? {
verdict: 'admitted',
refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession }
}
: null
if (args.resumeOrigin === 'saved') {
await captureSavedResumeCleanup(
ctx.deps.runtime,
ctx.deps.store,
ctx.stablePaneKey,
captured,
args.worktreeId
)
}
const chooseFresh = async (): Promise<void> => {
ctx.spawnOptions.env = { ...ctx.spawnOptions.env }
args.launchConfig = ctx.effectiveLaunchConfig
ctx.resumeAdmission = await chooseSavedResumeFreshLaunch(
ctx.deps.runtime,
args,
ctx.expectedWslDistro,
ctx.spawnOptions
)
if (captured && ctx.resumeAdmission) {
ctx.resumeAdmission.refused = captured.refused
ctx.resumeAdmission.sourceRecord = captured.sourceRecord
}
if (resumeSpawnRefusal(ctx.resumeAdmission)) {
ctx.launchCommand = undefined
ctx.env = ctx.spawnOptions.env
ctx.baseEnv = ctx.spawnOptions.env
ctx.spawnEnv = ctx.spawnOptions.env
}
}
try {
if (ctx.preAllocatedHandle) {
ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle)
@@ -65,9 +107,19 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
if (!ctx.preAdoptedStablePane && ctx.paneAgentLaunch) {
ctx.paneAgentLaunch.dispatched = true
}
ctx.result = attachOnlyViewOf(args)
? await attachViewWithoutOwner(ctx.provider, ctx.spawnOptions)
: await ctx.provider.spawn(ctx.spawnOptions)
if (attachOnlyViewOf(args)) {
ctx.result = await attachViewWithoutOwner(ctx.provider, ctx.spawnOptions)
} else {
await chooseFresh()
ctx.result = await ctx.provider.spawn(ctx.spawnOptions)
}
if (ctx.result.isReattach) {
ctx.resumeAdmission = null
Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id))
}
if (resumeSpawnRefusal(ctx.resumeAdmission)) {
ctx.effectiveLaunchConfig = undefined
}
ctx.stablePaneOwner = null
ctx.rejectedRegistrationCandidate = ctx.result
if (ctx.pendingRegistrationPtyId !== ctx.result.id) {
+41 -2
View File
@@ -28,6 +28,13 @@ import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-ma
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { refuseClosedPaneSpawn } from './closed-pane-spawn-refusal'
import {
captureSavedResumeCleanup,
admitColdRestoreReattach,
forgetRefusedResume,
resumeSpawnRefusal,
savedResumeRecord
} from '../host-env/saved-resume-admission'
function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void {
if (!ctx.agentTeamsLeaderHandle) {
@@ -81,11 +88,41 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
}
try {
await executePtyIpcSpawn(ctx)
if (!ctx.resumeAdmission) {
ctx.resumeAdmission = await admitColdRestoreReattach(
deps.runtime,
ctx.result,
{ paneKey: ctx.stablePaneKey, saved: savedResumeRecord(deps.store, ctx.stablePaneKey) },
ctx.expectedWslDistro
)
await captureSavedResumeCleanup(
deps.runtime,
deps.store,
ctx.stablePaneKey,
ctx.resumeAdmission,
args.worktreeId
)
}
const committed = await commitPtyIpcSpawn(ctx)
const refusal = resumeSpawnRefusal(ctx.resumeAdmission)
forgetRefusedResume(ctx.stablePaneKey, refusal)
if (args.worktreeId && ctx.stablePaneKey && refusal && refusal.verdict !== 'unverifiable') {
void deps.runtime
?.forgetRefusedSavedResume?.(
args.worktreeId,
ctx.stablePaneKey,
refusal.refused,
refusal.sourceRecord
)
.catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error))
}
endPaneAgentLaunch(
ctx,
ctx.exitedBeforeRegistration ? { exitedDuringStart: true } : { started: true }
)
if (!ctx.result.isReattach) {
deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed)
}
return committed
} finally {
if (ctx.pendingRegistrationPtyId) {
@@ -126,7 +163,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
if (adopted) {
releaseAbandonedAgentTeamsLeader(ctx)
}
return adopted?.result ?? completed!
return adopted
? { ...adopted.result, ...deps.runtime?.getTerminalLaunchResult?.(adopted.result.id) }
: completed!
}
return complete(operation)
}
@@ -178,7 +217,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, adopted.result.id, true)
})
endPaneAgentLaunch(ctx, { started: true })
return adopted.result
return { ...adopted.result, ...deps.runtime?.getTerminalLaunchResult?.(adopted.result.id) }
}
return freshResult!
} catch (caught) {
+4
View File
@@ -9,6 +9,7 @@ import type {
import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry'
import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import type { ResumeSpawnAdmission } from '../host-env/saved-resume-admission'
import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service'
import type { PtySpawnTiming } from '../pty-spawn-timing'
import { createPtySpawnTiming } from '../pty-spawn-timing'
@@ -86,6 +87,8 @@ export type PtyIpcSpawnState = {
nativeWindowsConptySpawn: boolean
codexSelectionTarget: CodexAccountSelectionTarget
codexResumeLaunch: CodexResumeLaunch
/** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */
resumeAdmission: ResumeSpawnAdmission | null
launchCommand: string | undefined
env: Record<string, string> | undefined
selectedCodexHomePath: string | null
@@ -164,6 +167,7 @@ export function createPtyIpcSpawnState(
nativeWindowsConptySpawn: false,
codexSelectionTarget: { runtime: 'host' },
codexResumeLaunch: noCodexResumeLaunch(undefined),
resumeAdmission: null,
launchCommand: undefined,
env: undefined,
selectedCodexHomePath: null,
+2
View File
@@ -33,6 +33,8 @@ export type PtySpawnIpcArgs = {
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
// Untyped on purpose: only `'saved'` marks a resume built from saved facts.
resumeOrigin?: unknown
launchToken?: unknown
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
+12 -1
View File
@@ -4,7 +4,7 @@ import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints'
import type { Store } from '../../../persistence'
import type { IPtyProvider } from '../../../providers/pty-provider-contract'
import { isCurrentPtyExit, ptyIncarnationById, ptyOwnership } from './ownership-state'
import { getRelayPtyId } from './registry'
import { getRelayPtyId, tryGetProviderForPty } from './registry'
import {
KEEP_HISTORY_STOP_POLL_MS,
KEEP_HISTORY_STOP_SETTLE_MS
@@ -127,3 +127,14 @@ export function finishPtyShutdown(
ptyOwnership.delete(id)
return incarnationId
}
export async function isRegisteredAgentSessionOwnerLive(
owner: AgentSessionOwnerBinding
): Promise<boolean> {
const ownerProvider = tryGetProviderForPty(owner.ptyId)
if (!ownerProvider) {
// Why: an owner on a host this process cannot reach is unknown, never absent.
throw new Error('execution_owner_unavailable')
}
return await isProviderAgentSessionOwnerLive(ownerProvider, owner)
}
@@ -3,7 +3,8 @@ import { join } from 'node:path'
import {
readFileSyncMock,
recordCodexPaneAccountMock,
forgetCodexPaneAccountMock
forgetCodexPaneAccountMock,
noChatsRuntimeMembers
} from '../pty-ipc-mock-registry'
import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from '../pty-ipc-test-constants'
import { setupPtyIpcSuite } from '../pty-ipc-test-harness'
@@ -287,6 +288,7 @@ describe('registerPtyHandlers', () => {
} as never)
const runtime = {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
@@ -357,6 +359,7 @@ describe('registerPtyHandlers', () => {
} as never)
const runtime = {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { piBuildPtyEnvMock } from '../pty-ipc-mock-registry'
import { piBuildPtyEnvMock, noChatsRuntimeMembers } from '../pty-ipc-mock-registry'
import { setupPtyIpcSuite } from '../pty-ipc-test-harness'
import {
type DaemonSpawnCall,
@@ -127,6 +127,7 @@ describe('registerPtyHandlers', () => {
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
@@ -512,6 +513,7 @@ describe('registerPtyHandlers', () => {
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
@@ -546,6 +548,7 @@ describe('registerPtyHandlers', () => {
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
@@ -578,6 +581,7 @@ describe('registerPtyHandlers', () => {
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
@@ -389,6 +389,12 @@ describe('registerPtyHandlers', () => {
expect(rendererResult).toEqual({ id: 'pty-renderer' })
expect(runtimeResult).toEqual({
id: 'pty-renderer',
isReattach: true,
launchAgent: undefined,
launchToken: undefined,
resumeProviderSession: undefined,
snapshotKittyKeyboardFlags: undefined,
wslDistro: undefined,
stablePaneOwner: {
handle: 'term_trusted',
tabId: 'tab-race',
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { readFileSyncMock } from '../pty-ipc-mock-registry'
import { readFileSyncMock, noChatsRuntimeMembers } from '../pty-ipc-mock-registry'
import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from '../pty-ipc-test-constants'
import { setupPtyIpcSuite } from '../pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from '../pty-ipc-daemon-provider-fixtures'
@@ -80,6 +80,7 @@ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home
function makeRuntime() {
return {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { spawnMock } from '../pty-ipc-mock-registry'
import { spawnMock, noChatsRuntimeMembers } from '../pty-ipc-mock-registry'
import { posixOnlyIt, TEST_MANAGED_ROOT } from '../pty-ipc-test-constants'
import { setupPtyIpcSuite } from '../pty-ipc-test-harness'
import { prepareCodexSessionResume } from '../../codex/codex-session-resume-preparation'
@@ -347,6 +347,7 @@ describe('registerPtyHandlers', () => {
const daemonSpawn = setupResumeDaemonProvider()
const runtime = {
setPtyController: vi.fn(),
...noChatsRuntimeMembers(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
@@ -47,12 +47,22 @@ describe('runtime spawn commit: adopted agent-session claim', () => {
it('commits the live grid from the adoption reply before the early return', async () => {
const { runtime, ctx } = makeAdoptedCtx({
isReattach: true,
launchAgent: 'codex',
launchToken: 'owner-token',
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
})
await commitRuntimePtySpawn(ctx)
const reply = await commitRuntimePtySpawn(ctx)
expect(reply).toMatchObject({
id: PTY_ID,
isReattach: true,
launchAgent: 'codex',
launchToken: 'owner-token',
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
})
expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID)
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(
PTY_ID,
@@ -4,9 +4,22 @@ import { reportRuntimeResumeOutcome } from './spawn-commit-response'
import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
type Facts = { resumed?: boolean; unavailable?: boolean; dropped?: boolean; isReattach?: boolean }
type Facts = {
resumed?: boolean
unavailable?: boolean
dropped?: boolean
isReattach?: boolean
refused?: boolean
}
function reply({ resumed = false, unavailable = false, dropped = false, isReattach }: Facts) {
/** What a committed spawn tells its start, from a start that did or did not ask to resume. */
function reply({
resumed = false,
unavailable = false,
dropped = false,
isReattach,
refused
}: Facts) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the report reads no deps.
const deps = {} as unknown as PtyRuntimeControllerDeps
const onResumeOutcome = vi.fn()
@@ -18,7 +31,13 @@ function reply({ resumed = false, unavailable = false, dropped = false, isReatta
...(resumed ? { resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } } : {})
} as unknown as RuntimePtySpawnArgs
const ctx = createRuntimePtySpawnState(deps, args)
ctx.result = { id: 'pty-1', ...(isReattach ? { isReattach } : {}) }
ctx.result = {
id: 'pty-1',
...(isReattach ? { isReattach } : {}),
...(refused
? { agentResumeRefused: 'chat-home' as const, agentResumeUnavailable: true as const }
: {})
}
ctx.codexResumeUnavailable = unavailable
ctx.codexResumeArgvDropped = dropped || unavailable
reportRuntimeResumeOutcome(ctx)
@@ -39,9 +58,17 @@ describe("a runtime spawn's resume outcome", () => {
expect(reply({ resumed: true, unavailable: true }).resumeOutcome).toBe(on('resume-unavailable'))
})
it('reports the effective refused shell after admission removed its requested resume', () => {
expect(reply({ refused: true })).toEqual({
resumeOutcome: on('resume-unavailable'),
ptyId: on('pty-1')
})
})
it('says nothing for a plain start, a silently dropped resume, or a reattach', () => {
expect(reply({}).resumeOutcome).toBeUndefined()
expect(reply({ resumed: true, dropped: true }).resumeOutcome).toBeUndefined()
expect(reply({ resumed: true, isReattach: true }).resumeOutcome).toBeUndefined()
expect(reply({ refused: true, isReattach: true }).resumeOutcome).toBeUndefined()
})
})
@@ -10,14 +10,15 @@ type RuntimeResumeOutcomeState = Pick<
/** Whether a fresh start resumed the agent it was asked to; a reattach ran no launch command. */
export function resumeOutcomeOf(ctx: RuntimeResumeOutcomeState): PaneResumeOutcome | null {
if (!ctx.args.resumeProviderSession || ctx.result.isReattach) {
if (ctx.result.isReattach) {
return null
}
if (ctx.codexResumeUnavailable) {
// Refused saved admission removes the request; the spawned shell still carries its outcome.
if (ctx.result.agentResumeUnavailable || ctx.codexResumeUnavailable) {
return 'resume-unavailable'
}
// A resume dropped silently named another agent's session: nothing came back, and nothing is owed.
return ctx.codexResumeArgvDropped ? null : 'restored'
return ctx.args.resumeProviderSession && !ctx.codexResumeArgvDropped ? 'restored' : null
}
/** Publish resume outcome before binding so the first frame with the new terminal includes it. */
@@ -112,33 +112,39 @@ describe('runtime spawn commit: run facts', () => {
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([])
})
it('reports the resumed agent before the bind can publish its first frame', async () => {
const onResumeOutcome = vi.fn()
const persistPtyBinding = vi.fn(async () => {
expect(onResumeOutcome.mock.calls).toEqual(
ORCA_LAYOUT_OWNER_IS_MAIN ? [[PTY_ID, 'resume-unavailable']] : []
)
return true
})
it.each([false, true])(
'reports unavailable before binding even when the request was erased (%s)',
async (erased) => {
const onResumeOutcome = vi.fn()
const persistPtyBinding = vi.fn(async () => {
expect(onResumeOutcome.mock.calls).toEqual(
ORCA_LAYOUT_OWNER_IS_MAIN ? [[PTY_ID, 'resume-unavailable']] : []
)
return true
})
await commit({}, undefined, undefined, (ctx) => {
ctx.args.resumeProviderSession = { key: 'session_id', id: 'codex-session-1' }
ctx.args.onResumeOutcome = onResumeOutcome
ctx.codexResumeUnavailable = true
ctx.codexResumeArgvDropped = true
ctx.hostSessionBinding = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this boundary pin observes the bind write without invoking its stored callback.
store: { persistPtyBinding } as unknown as NonNullable<
typeof ctx.hostSessionBinding
>['store'],
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'leaf-1'
}
})
await commit({}, undefined, undefined, (ctx) => {
ctx.args.resumeProviderSession = erased
? undefined
: { key: 'session_id', id: 'codex-session-1' }
ctx.result.agentResumeUnavailable = erased ? true : undefined
ctx.args.onResumeOutcome = onResumeOutcome
ctx.codexResumeUnavailable = !erased
ctx.codexResumeArgvDropped = true
ctx.hostSessionBinding = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this boundary pin observes the bind write without invoking its stored callback.
store: { persistPtyBinding } as unknown as NonNullable<
typeof ctx.hostSessionBinding
>['store'],
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'leaf-1'
}
})
expect(persistPtyBinding).toHaveBeenCalledOnce()
})
expect(persistPtyBinding).toHaveBeenCalledOnce()
}
)
it('records nothing for a spawn discarded because its binding save failed', async () => {
const facts = new TerminalRunFactsRegister()
+2
View File
@@ -42,6 +42,8 @@ export function adoptMaterializedRuntimePtySpawn(
throw new Error('terminal_pane_owner_unknown')
}
ctx.result = {
...ctx.preAdoptedStablePane.result,
isReattach: true,
id: ctx.preAdoptedStablePane.result.id,
...(ctx.preAdoptedStablePane.result.incarnationId
? { incarnationId: ctx.preAdoptedStablePane.result.incarnationId }
+124 -47
View File
@@ -1,7 +1,15 @@
import {
suppressSavedResumeLaunch,
captureSavedResumeCleanup,
chooseSavedResumeFreshLaunch,
type ResumeSpawnAdmission,
resumeSpawnRefusal,
SavedResumeFreshRefusal
} from '../host-env/saved-resume-admission'
import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority'
import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
import { ptyIncarnationById } from '../provider/ownership-state'
import { tryGetProviderForPty } from '../provider/registry'
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach'
import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate'
import {
@@ -15,13 +23,46 @@ import {
excludeAbsentOwnerReplay,
PaneOwnerContentionError
} from '../pane/pane-spawn-operation'
import { isProviderAgentSessionOwnerLive } from '../provider/liveness'
import { isRegisteredAgentSessionOwnerLive } from '../provider/liveness'
import { failPtySpawnExecute } from '../spawn-execute-failure'
import type { RuntimePtySpawnState } from './spawn-state'
import { markRuntimeSpawnHiddenBeforeSpawn } from './spawn-hidden-delivery'
export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise<void> {
const args = ctx.args
const captured: ResumeSpawnAdmission | null =
args.launchAgent && args.resumeProviderSession
? {
verdict: 'admitted',
refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession }
}
: null
if (args.resumeOrigin === 'saved') {
await captureSavedResumeCleanup(
ctx.deps.runtime,
ctx.deps.store,
ctx.spawnIdentityPaneKey,
captured,
args.worktreeId
)
}
const chooseFresh = async (): Promise<void> => {
ctx.spawnOptions.env = { ...ctx.spawnOptions.env }
ctx.resumeAdmission = await chooseSavedResumeFreshLaunch(
ctx.deps.runtime,
args,
ctx.expectedWslDistro,
ctx.spawnOptions
)
if (captured && ctx.resumeAdmission) {
ctx.resumeAdmission.refused = captured.refused
ctx.resumeAdmission.sourceRecord = captured.sourceRecord
}
if (resumeSpawnRefusal(ctx.resumeAdmission)) {
ctx.launchCommand = undefined
ctx.env = ctx.spawnOptions.env
}
}
const runtime = ctx.deps.runtime
const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn
ctx.releaseWorktreeSpawn = acquireWorktreeSpawn
@@ -111,62 +152,98 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
ctx.pendingRegistrationPtyId = recoveredOwner.ptyId
}
let providerResult: PtySpawnResult | null = null
const ensured = await agentSessionOwners.ensure({
claim: args.agentSessionEnsure.claim,
surface: args.agentSessionEnsure.surface,
spawn: async () => {
assertClientStillConnected()
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
ctx.rejectedRegistrationCandidate = providerResult
// Lower-owner success proves physical work committed even if admission observes early exit.
ctx.reportPtySpawnCommitted()
assertSpawnReplyWasLive(providerResult)
ctx.deps.runtime?.assertPtyRegistrationAllowed?.(
providerResult.id,
providerResult.incarnationId
)
if (providerResult.incarnationId) {
// In-process providers cannot list controller claims; record exact incarnation before ownership promotion.
ptyIncarnationById.set(providerResult.id, providerResult.incarnationId)
let ensured: AgentSessionClaimedSpawnResult | undefined
try {
ensured = await agentSessionOwners.ensure({
claim: args.agentSessionEnsure.claim,
surface: args.agentSessionEnsure.surface,
spawn: async () => {
assertClientStillConnected()
await chooseFresh()
const refusal = resumeSpawnRefusal(ctx.resumeAdmission)
if (refusal) {
throw new SavedResumeFreshRefusal(refusal)
}
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
ctx.rejectedRegistrationCandidate = providerResult
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
ctx.reportPtySpawnCommitted()
assertSpawnReplyWasLive(providerResult)
ctx.deps.runtime?.assertPtyRegistrationAllowed?.(
providerResult.id,
providerResult.incarnationId
)
if (providerResult.incarnationId) {
// Why: local providers cannot serialize controller claims, so liveness proof
// needs the exact incarnation before the registry promotes the new owner.
ptyIncarnationById.set(providerResult.id, providerResult.incarnationId)
}
const providerEnsure = providerResult.agentSessionEnsure
return {
ptyId: providerResult.id,
...(providerEnsure
? {
owner: providerEnsure.owner,
disposition: providerEnsure.disposition
}
: {})
}
},
isLive: isRegisteredAgentSessionOwnerLive,
onJoin: () => {
awaitingAnotherStart = true
}
const providerEnsure = providerResult.agentSessionEnsure
return {
ptyId: providerResult.id,
...(providerEnsure
? {
owner: providerEnsure.owner,
disposition: providerEnsure.disposition
}
: {})
}
},
isLive: async (owner) => {
const ownerProvider = tryGetProviderForPty(owner.ptyId)
if (!ownerProvider) {
// Unreachable execution owners remain unknown.
throw new Error('execution_owner_unavailable')
}
return await isProviderAgentSessionOwnerLive(ownerProvider, owner)
},
onJoin: () => {
awaitingAnotherStart = true
})
} catch (error) {
if (!(error instanceof SavedResumeFreshRefusal)) {
throw error
}
})
// The claim reservation rejected before spawn; this shell must never become its owner.
ctx.resumeAdmission = {
...error.admission,
refused: captured?.refused ?? error.admission.refused,
sourceRecord: captured?.sourceRecord
}
suppressSavedResumeLaunch(args, ctx.spawnOptions)
ctx.launchCommand = undefined
ctx.env = ctx.spawnOptions.env
delete args.agentSessionEnsure
ctx.spawnOptions.agentSessionEnsure = undefined
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
ctx.reportPtySpawnCommitted()
ctx.stablePaneOwner = null
}
awaitingAnotherStart = false
ctx.result = providerResult ?? {
id: ensured.owner.ptyId,
isReattach: true,
// Preserve listing-proven incarnation to reject delayed exits from older processes.
incarnationId: ptyIncarnationById.get(ensured.owner.ptyId)
if (providerResult) {
ctx.result = providerResult
} else if (ensured) {
ctx.result = {
id: ensured.owner.ptyId,
isReattach: true,
// A listing's incarnation rejects delayed exits from an older process.
incarnationId: ptyIncarnationById.get(ensured.owner.ptyId)
}
} else {
throw new Error('agent_session_owner_missing')
}
if (ensured) {
ctx.result.agentSessionEnsure = ensured
}
ctx.result.agentSessionEnsure = ensured
} else {
assertClientStillConnected()
await chooseFresh()
ctx.result = await ctx.provider.spawn(ctx.spawnOptions)
ctx.reportPtySpawnCommitted()
ctx.stablePaneOwner = null
ctx.rejectedRegistrationCandidate = ctx.result
}
if (ctx.result.isReattach || ctx.result.agentSessionEnsure?.disposition === 'adopted') {
ctx.result.isReattach = true
ctx.resumeAdmission = null
}
if (ctx.result.isReattach) {
Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id))
}
ctx.rejectedRegistrationCandidate ??= ctx.result
if (ctx.pendingRegistrationPtyId !== ctx.result.id) {
if (ctx.pendingRegistrationPtyId) {
@@ -0,0 +1,416 @@
import { CLAUDE_CLI_CONVERSATIONS } from '../../../claude/claude-cli-conversations'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { localProvider } from '../provider/registry'
import { spawnPtyFromRuntimeController } from './spawn'
import * as owners from '../pane/agent-session-owners'
import { describe, expect, it, vi } from 'vitest'
import type { BrowserWindow } from 'electron'
import { getDefaultSettings } from '../../../../shared/constants'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../../shared/agent-session-record.test-fixture'
import { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { finishPtyShutdown } from '../provider/liveness'
import { listStructuredProviderSessionOwnership } from '../../../native-chat/agent-session-wire/structured-provider-session-ownership'
import type { PtyRuntimeControllerDeps } from './controller-deps'
import { executeRuntimePtySpawn } from './spawn-execute'
import {
admitColdRestoreReattach,
admitResumeSpawn,
savedResumeResult
} from '../host-env/saved-resume-admission'
import { createRuntimePtySpawnState } from './spawn-state'
const forgetRefusedPaneResume = vi.hoisted(() => vi.fn())
vi.mock('../../../agent-hooks/server', () => ({ agentHookServer: { forgetRefusedPaneResume } }))
vi.mock('./spawn-preflight', () => ({ prepareRuntimePtySpawn: vi.fn(async () => null) }))
vi.mock('./spawn-options', () => ({
buildRuntimePtySpawnOptions: vi.fn(async (ctx: ReturnType<typeof createRuntimePtySpawnState>) => {
ctx.spawnIdentityPaneKey = 'tab-1:leaf'
ctx.spawnOptions = {
cols: ctx.args.cols,
rows: ctx.args.rows,
command: ctx.args.command,
env: ctx.args.env,
launchAgent: ctx.args.launchAgent
}
return null
})
}))
const C = 'provider-session-alpha-1'
const chat = agentSessionRecordFixture(
agentSessionLeaseFixture({
claimStatus: 'released',
ownerProcess: null,
reservedSpawnToken: null,
deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
})
)
const store = {
listRecords: () => [chat],
getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [chat.sessionId] })
}
/** A host whose one chat continues C at rest, with its tab open. */
function chatHost() {
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the two members `store` has.
openAgentSessionRecordStore: async () => store as unknown as AgentSessionRecordStore,
getTerminalLaunchResult: vi.fn(),
forgetRefusedSavedResume: vi.fn(async () => {}),
getAgentProviderSessionRows: () => [
{
paneKey: 'tab-1:leaf',
connectionId: null,
agentType: 'claude' as const,
state: 'working' as const,
prompt: '',
receivedAt: 1,
stateStartedAt: 1,
providerSession: { key: 'session_id' as const, id: C }
}
]
}
}
function deps(runtime: ReturnType<typeof chatHost>): PtyRuntimeControllerDeps {
const noCodexResumeLaunch: PtyRuntimeControllerDeps['noCodexResumeLaunch'] = (command) => ({
codexResumeHome: null,
command,
notifyResumeUnavailable: false,
droppedResumeArgv: false,
providerSession: null
})
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preflight reads only the admission members `chatHost` provides.
runtime: runtime as unknown as OrcaRuntimeService,
store: undefined,
getSettings: () => getDefaultSettings('/tmp'),
adoptStablePane: async () => null,
getLocalPtyStartupPromise: () => undefined,
getLocalPtyProviderStartupPromise: () => undefined,
prepareCodexResumeHome: () => null,
resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command),
noCodexResumeLaunch,
reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath,
stripSequencedStartupResumeArgv: (env) => env,
assertWorkspaceAcceptsNewPty: () => undefined,
resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd,
requestSerializedBuffer: async () => null,
shutdownProviderAndDetectExit: async () => false,
rememberSyntheticKillExit: () => {},
rememberRetiredRejectedPty: () => {},
sendPtyExitToRenderer: () => {},
sendPtySpawnedToRenderer: () => {},
finishPtyShutdown,
trustedTerminalHandleEnv: new Set(),
retiredRejectedPtyIds: new Map(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the spawn preflight never touches `mainWindow`.
mainWindow: {} as BrowserWindow
}
}
describe('actual runtime saved-resume outcomes', () => {
function freshContext(runtime = chatHost()) {
const ctx = createRuntimePtySpawnState(deps(runtime), {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
command: `claude --resume ${C}`,
launchAgent: 'claude',
resumeProviderSession: { key: 'session_id', id: C },
resumeOrigin: 'saved',
launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: { AGENT_ONLY: 'yes' } },
env: {
WORKSPACE: 'keep',
AGENT_ONLY: 'yes',
ORCA_AGENT_LAUNCH_TOKEN: 'rejected',
ORCA_SEQUENCED_STARTUP_COMMAND: `claude --resume ${C}`,
ORCA_SEQUENCED_STARTUP_SCRIPT: `fallback claude --resume ${C}`
}
})
ctx.spawnOptions = {
cols: 80,
rows: 24,
command: ctx.args.command,
launchAgent: 'claude',
env: { ...ctx.args.env }
}
return ctx
}
it.each([10, 100])(
'counts real saved-spawn projection and freshness across %i chats',
async (size) => {
const records = Array.from({ length: size }, (_, index) => ({
...chat,
sessionId: `chat-${index}`,
providerHandleChain: chat.providerHandleChain.map((link) => ({
...link,
handle: { ...link.handle, nativeId: `unrelated-${index}` }
}))
}))
const list = vi.spyOn(store, 'listRecords').mockImplementation(() => records)
const visible = vi.spyOn(store, 'getVisibleSessionTabIndex').mockReturnValue({
present: true,
sessionIds: records.map((record) => record.sessionId)
})
const read = vi.spyOn(CLAUDE_CLI_CONVERSATIONS.sessionHistory, 'rowSessionId')
const runtime = chatHost()
let rowReads = 0
const originalRow = runtime.getAgentProviderSessionRows()[0]!
const rows = Array.from({ length: size }, (_, index) => ({
...originalRow,
get paneKey() {
rowReads += 1
return index === size - 1 ? 'tab-1:leaf' : `unrelated-pane-${index}`
}
}))
runtime.getAgentProviderSessionRows = () => rows
const run = async (ordinary = false) => {
const ctx = freshContext(runtime)
ctx.spawnIdentityPaneKey = 'tab-1:leaf'
if (ordinary) {
delete ctx.args.resumeOrigin
}
await executeRuntimePtySpawn(ctx)
return ctx
}
const spawn = vi.spyOn(localProvider, 'spawn').mockResolvedValue({ id: 'counted-shell' })
try {
await admitColdRestoreReattach(
runtime,
{ coldRestore: {}, isReattach: true },
{ paneKey: 'tab-1:leaf', saved: undefined },
null
)
await run(true)
expect([
list.mock.calls.length,
visible.mock.calls.length,
read.mock.calls.length,
rowReads
]).toEqual([0, 0, 0, 0])
for (let pane = 0; pane < size; pane += 1) {
expect((await run()).resumeAdmission?.verdict).toBe('admitted')
if (pane === 0) {
expect([read.mock.calls.length, rowReads]).toEqual([3 * size, size])
}
}
// Each pane projects N heads and two handle reads per link; P=N exposes the aggregate cost.
expect(read.mock.calls.length).toBe(size * 3 * size)
expect([list.mock.calls.length, visible.mock.calls.length, rowReads]).toEqual([
size,
size,
size * size
])
const target = records[0]!
target.providerHandleChain[0]!.handle.nativeId = C
const refused = await run()
expect(refused.resumeAdmission?.verdict).toBe('chat-home')
expect(refused.spawnOptions.command).toBeUndefined()
target.providerHandleChain[0]!.handle.nativeId = 'successor-conversation'
expect((await run()).resumeAdmission?.verdict).toBe('admitted')
expect(read.mock.calls.length).toBe((size + 2) * 3 * size)
} finally {
spawn.mockRestore()
read.mockRestore()
list.mockRestore()
visible.mockRestore()
}
}
)
it('chooses a plain shell only in the fresh callback and does not forget before commit', async () => {
const runtime = chatHost()
const ctx = freshContext(runtime)
const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'plain-shell' })
try {
expect(ctx.args.command).toContain(C)
await executeRuntimePtySpawn(ctx)
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
env: { WORKSPACE: 'keep' }
})
)
expect(ctx.spawnOptions.command).toBeUndefined()
expect(ctx.args.launchAgent).toBeUndefined()
expect(savedResumeResult(ctx.resumeAdmission)).toMatchObject({
agentResumeRefused: 'chat-home',
agentResumeUnavailable: true,
savedResumeDecision: { verdict: 'chat-home', providerSession: { id: C } }
})
expect(forgetRefusedPaneResume).not.toHaveBeenCalled()
expect(runtime.forgetRefusedSavedResume).not.toHaveBeenCalled()
} finally {
spawn.mockRestore()
}
})
it('lets the actual lower-owned live result override a tentative fresh refusal', async () => {
const runtime = chatHost()
const ctx = freshContext(runtime)
runtime.getTerminalLaunchResult.mockReturnValue({ launchAgent: 'codex' })
const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({
id: 'already-live',
isReattach: true,
launchAgent: 'codex'
})
try {
await executeRuntimePtySpawn(ctx)
expect(ctx.result).toMatchObject({
id: 'already-live',
isReattach: true,
launchAgent: 'codex'
})
expect(ctx.resumeAdmission).toBeNull()
expect(runtime.forgetRefusedSavedResume).not.toHaveBeenCalled()
} finally {
spawn.mockRestore()
}
})
it('checks the selected late cold-shell candidate but never checks a real reattach', async () => {
const runtime = chatHost()
const pane = { paneKey: 'tab-1:leaf', saved: undefined }
await expect(
admitColdRestoreReattach(
runtime,
{ coldRestore: { scrollback: '', cwd: '/tmp' } },
pane,
null
)
).resolves.toMatchObject({ verdict: 'chat-home', refused: { providerSession: { id: C } } })
await expect(
admitColdRestoreReattach(runtime, { coldRestore: {}, isReattach: true }, pane, null)
).resolves.toBeNull()
})
})
it('unwinds a refused fresh claim before spawning an unclaimed shell', async () => {
const runtime = chatHost()
const ctx = createRuntimePtySpawnState(deps(runtime), {
cols: 80,
rows: 24,
launchAgent: 'claude',
command: `claude --resume ${C}`,
resumeOrigin: 'saved',
resumeProviderSession: { key: 'session_id', id: C },
agentSessionEnsure: {
claim: {
agent: 'claude',
digestVersion: 1,
keyId: 'key',
identityDigest: 'C',
worktreeScopeDigest: 'scope'
},
surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf', terminalHandle: 'terminal' }
}
})
const claim = ctx.args.agentSessionEnsure?.claim
if (!claim) {
throw new Error('missing fixture claim')
}
ctx.spawnOptions = {
cols: 80,
rows: 24,
command: ctx.args.command,
agentSessionEnsure: ctx.args.agentSessionEnsure
}
const inventory = vi.spyOn(owners, 'reconcileAgentSessionOwnerListings').mockResolvedValue()
const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'refused-shell' })
try {
await executeRuntimePtySpawn(ctx)
expect(spawn).toHaveBeenCalledOnce()
expect(ctx.spawnOptions.command).toBeUndefined()
expect(ctx.result.agentSessionEnsure).toBeUndefined()
expect(owners.agentSessionOwners.find(claim)).toBeNull()
expect(ctx.resumeAdmission).toMatchObject({ verdict: 'chat-home' })
} finally {
inventory.mockRestore()
spawn.mockRestore()
}
})
it('returns the committed runtime shell when exact saved-record persistence fails', async () => {
const runtime = {
...chatHost(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
reflowHeadlessTerminalToPtyGrid: vi.fn(),
forgetRefusedSavedResume: vi.fn(async () => {
throw new Error('saved record disk full')
})
}
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
const spawn = vi.spyOn(localProvider, 'spawn').mockResolvedValue({ id: 'cleanup-failed-shell' })
const onResumeOutcome = vi.fn()
try {
const result = await spawnPtyFromRuntimeController(deps(runtime), {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
command: `claude --resume ${C}`,
launchAgent: 'claude',
resumeOrigin: 'saved',
resumeProviderSession: { key: 'session_id', id: C },
onResumeOutcome
})
expect(result).toMatchObject({ id: 'cleanup-failed-shell', agentResumeRefused: 'chat-home' })
expect(spawn.mock.calls[0]?.[0].command).toBeUndefined()
expect(runtime.registerPty).toHaveBeenCalled()
expect(runtime.forgetRefusedSavedResume).toHaveBeenCalledOnce()
expect(onResumeOutcome.mock.calls).toEqual(
ORCA_LAYOUT_OWNER_IS_MAIN ? [['cleanup-failed-shell', 'resume-unavailable']] : []
)
await Promise.resolve()
expect(warning).toHaveBeenCalled()
} finally {
spawn.mockRestore()
warning.mockRestore()
}
})
it('preserves case-distinct conversation heads and saved homes across the shared OpenCode family', async () => {
const ids = ['ses_000000000001AAAAAAAAAAAAAA', 'ses_000000000001aaaaaaaaaaaaaa']
const record = {
...chat,
provider: 'opencode' as const,
providerHandleChain: ids.map((nativeId, index) => ({
...chat.providerHandleChain[0]!,
linkId: `case-${index}`,
handle: { transport: 'acp' as const, agent: 'opencode' as const, nativeId }
}))
}
const opencodeStore: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
opencodeStore.listRecords = () => [record]
opencodeStore.getVisibleSessionTabIndex = () => ({
present: true,
sessionIds: [record.sessionId]
})
const runtime = { openAgentSessionRecordStore: async () => opencodeStore }
expect(listStructuredProviderSessionOwnership([record]).map((holding) => holding.role)).toEqual([
'earlier',
'head'
])
for (const [index, id] of ids.entries()) {
await expect(
admitResumeSpawn(
runtime,
{
launchAgent: 'opencode2',
resumeOrigin: 'saved',
resumeProviderSession: { key: 'session_id', id }
},
null
)
).resolves.toMatchObject({ verdict: index === 0 ? 'admitted' : 'chat-home' })
}
})
@@ -144,6 +144,13 @@ export async function prepareRuntimePtySpawn(
provider: ctx.provider,
deps: ctx.deps
})
ctx.agentResumeUnavailable = hostEnv.codexResumeLaunch.notifyResumeUnavailable
if (hostEnv.codexResumeLaunch.droppedResumeArgv && args.launchConfig) {
args.launchConfig = {
...args.launchConfig,
agentCommand: hostEnv.launchCommand ?? args.launchConfig.agentCommand
}
}
ctx.launchCommand = hostEnv.launchCommand
ctx.codexResumeArgvDropped = hostEnv.codexResumeLaunch.droppedResumeArgv
ctx.codexResumeUnavailable = hostEnv.codexResumeLaunch.notifyResumeUnavailable
+14 -1
View File
@@ -15,9 +15,13 @@ import type { PaneSpawnReservation } from '../pane/spawn-reservation'
import type { AdoptStablePaneResult } from '../ipc/spawn-types'
import type { PtyBindingSourceExpectation } from '../../../persistence'
import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement'
import type { ResumeSpawnAdmission } from '../host-env/saved-resume-admission'
import type { PtyRuntimeControllerDeps } from './controller-deps'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
import type {
SleepingAgentLaunchConfig,
AgentProviderSessionMetadata
} from '../../../../shared/agent-session-resume'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type {
AgentSessionExecutionClaim,
@@ -51,6 +55,8 @@ export type RuntimePtySpawnState = {
effectiveSessionAppId: string | undefined
isNewDaemonSession: boolean
expectedWslDistro: string | null
/** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */
resumeAdmission: ResumeSpawnAdmission | null
codexSelectionTarget: CodexAccountSelectionTarget
launchCommand: string | undefined
claudeAuth: ClaudeRuntimeAuthPreparation | null
@@ -69,6 +75,7 @@ export type RuntimePtySpawnState = {
requestedAgentTeamsPath: string | undefined
selectedCodexHomePath: string | null
codexResumeHomeSelected: boolean
agentResumeUnavailable: boolean
codexResumeArgvDropped: boolean
/** The dropped resume owes the user a notice: the pane runs a fresh session. */
codexResumeUnavailable: boolean
@@ -107,6 +114,8 @@ export type RuntimePtySpawnArgs = {
cwd?: string
command?: string
launchAgent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: StartupCommandDelivery
telemetry?: {
@@ -117,6 +126,8 @@ export type RuntimePtySpawnArgs = {
env?: Record<string, string>
envToDelete?: string[]
resumeProviderSession?: AgentProviderSessionMetadata
/** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */
resumeOrigin?: 'saved'
connectionId?: string | null
worktreeId?: string
preAllocatedHandle?: string
@@ -186,6 +197,7 @@ export function createRuntimePtySpawnState(
effectiveSessionAppId: undefined,
isNewDaemonSession: false,
expectedWslDistro: null,
resumeAdmission: null,
codexSelectionTarget: { runtime: 'host' },
launchCommand: undefined,
claudeAuth: null,
@@ -195,6 +207,7 @@ export function createRuntimePtySpawnState(
requestedAgentTeamsPath: undefined,
selectedCodexHomePath: null,
codexResumeHomeSelected: false,
agentResumeUnavailable: false,
codexResumeArgvDropped: false,
codexResumeUnavailable: false,
skipCodexHomeEnv: false,
+69 -22
View File
@@ -1,5 +1,12 @@
import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority'
import type { StartupCwdFallback } from '../../../../shared/startup-cwd-fallback'
import {
captureSavedResumeCleanup,
admitColdRestoreReattach,
savedResumeRecord,
savedResumeResult,
resumeSpawnRefusal,
forgetRefusedResume
} from '../host-env/saved-resume-admission'
import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
import { isTerminalLeafId, parsePaneKey } from '../../../../shared/stable-pane-id'
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { rejectPaneSpawnReservation } from '../pane/spawn-reservation'
@@ -16,24 +23,13 @@ import {
releaseRuntimeSpawnPreSpawnHiddenMark
} from './spawn-hidden-delivery'
function toRuntimeSpawnReply(result: {
id: string
incarnationId?: string
wslDistro?: string | null
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
agentSessionEnsure?: AgentSessionClaimedSpawnResult
startupCwdFallback?: StartupCwdFallback
savedStartupCwd?: string
}) {
return {
savedStartupCwd: result.savedStartupCwd,
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}),
...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(result.startupCwdFallback ? { startupCwdFallback: result.startupCwdFallback } : {})
function toRuntimeSpawnReply(
result: PtySpawnResult & {
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
savedStartupCwd?: string
}
) {
return { ...result, wslDistro: result.wslDistro ?? undefined }
}
export async function spawnPtyFromRuntimeController(
@@ -72,6 +68,7 @@ export async function spawnPtyFromRuntimeController(
if (!adopted) {
return toRuntimeSpawnReply(freshResult!)
}
Object.assign(adopted.result, deps.runtime?.getTerminalLaunchResult?.(adopted.result.id))
if (!args.worktreeId) {
return toRuntimeSpawnReply(adopted.result)
}
@@ -92,11 +89,11 @@ async function spawnFreshPtyFromRuntimeController(
const materialized =
materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise
if (materialized) {
return toRuntimeSpawnReply(materialized)
return { ...materialized, ...deps.runtime?.getTerminalLaunchResult?.(materialized.id) }
}
const earlyAdopt = await prepareRuntimePtySpawn(ctx)
if (earlyAdopt) {
return toRuntimeSpawnReply(earlyAdopt)
return { ...earlyAdopt, ...deps.runtime?.getTerminalLaunchResult?.(earlyAdopt.id) }
}
const earlyReserved = await buildRuntimePtySpawnOptions(ctx).catch((error: unknown) => {
restoreProvisionalPtySize({
@@ -108,13 +105,60 @@ async function spawnFreshPtyFromRuntimeController(
throw error
})
if (earlyReserved) {
return toRuntimeSpawnReply(earlyReserved)
return { ...earlyReserved, ...deps.runtime?.getTerminalLaunchResult?.(earlyReserved.id) }
}
const complete = async (operation = args.paneSpawnOperation) => {
ctx.args.paneSpawnOperation = operation
await executeRuntimePtySpawn(ctx)
if (!ctx.resumeAdmission) {
ctx.resumeAdmission = await admitColdRestoreReattach(
deps.runtime,
ctx.result,
{
paneKey: ctx.spawnIdentityPaneKey,
saved: savedResumeRecord(deps.store, ctx.spawnIdentityPaneKey)
},
ctx.expectedWslDistro
)
await captureSavedResumeCleanup(
deps.runtime,
deps.store,
ctx.spawnIdentityPaneKey,
ctx.resumeAdmission,
args.worktreeId
)
}
if (!ctx.result.isReattach) {
Object.assign(ctx.result, savedResumeResult(ctx.resumeAdmission), {
...(ctx.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}),
launchConfig: args.launchConfig,
launchAgent: args.launchAgent,
launchToken: args.launchToken,
resumeProviderSession: ctx.codexResumeArgvDropped ? undefined : args.resumeProviderSession
})
}
const committed = await commitRuntimePtySpawn(ctx)
const refusal = resumeSpawnRefusal(ctx.resumeAdmission)
forgetRefusedResume(ctx.spawnIdentityPaneKey, refusal)
if (
args.worktreeId &&
ctx.spawnIdentityPaneKey &&
refusal &&
refusal.verdict !== 'unverifiable'
) {
void deps.runtime
?.forgetRefusedSavedResume?.(
args.worktreeId,
ctx.spawnIdentityPaneKey,
refusal.refused,
refusal.sourceRecord
)
.catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error))
}
commitRuntimeSpawnHiddenDelivery(ctx)
if (!ctx.result.isReattach) {
deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed)
}
return committed
}
const latePane =
@@ -135,6 +179,9 @@ async function spawnFreshPtyFromRuntimeController(
preAllocatedHandle: args.preAllocatedHandle,
spawnFresh: async (operation) => (result = await complete(operation))
})
if (adopted) {
Object.assign(adopted.result, deps.runtime?.getTerminalLaunchResult?.(adopted.result.id))
}
return toRuntimeSpawnReply(adopted?.result ?? result!)
}
return await complete()
+3 -7
View File
@@ -198,7 +198,7 @@ export async function commitPtySpawn<Reply extends PtySpawnResult>(
return resolvePaneSpawnReservation(
state.paneSpawnReservationKey,
state.paneSpawnReservation,
result
input.joinPayload.reply
)
}
} else if (input.earlyExit === 'throw') {
@@ -225,7 +225,7 @@ function commitAdoptedAgentSession<Reply extends PtySpawnResult>(
input: PtySpawnCommitInput<Reply>,
adopted: AgentSessionClaimedSpawnResult,
providerReattachLaunchIdentity: ReturnType<typeof admitProviderReattachLaunchIdentity>
): Pick<PtySpawnResult, 'id' | 'incarnationId' | 'agentSessionEnsure'> | Promise<never> {
): PtySpawnResult | Promise<never> {
const { state } = input
const { result } = state
const runtime = state.deps.runtime
@@ -272,9 +272,5 @@ function commitAdoptedAgentSession<Reply extends PtySpawnResult>(
state.paneSpawnReservation,
adoptedResult
)
return {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
agentSessionEnsure: adopted
}
return adoptedResult
}
@@ -43,7 +43,16 @@ export async function acquireOwner(
throw new AgentSessionPreSpawnError(error)
}
const permissionMode = await input.launchPermissionMode?.(record)
// Recheck after waits so Stop/close/quit cannot spawn a late child.
try {
await input.assertNoTerminalWriter?.(record)
} catch (error) {
// Not a code: a code message would travel as the census's own conflict, not this reason.
throw new AgentSessionPreSpawnError(error, {
reason: 'conversationOpenInTerminal',
message: 'a terminal may still be writing this conversation'
})
}
// Right before the spawn: a close, Stop or quit that landed during any wait above launches nothing.
if (input.acquireSignal?.aborted) {
throw new AgentSessionPreSpawnError(abortSignalReason(input.acquireSignal))
}
@@ -45,6 +45,7 @@ export type AgentSessionPreSpawnReason = Extract<
| 'claudeAccountSetupFailed'
| 'agentCommandNotRunnable'
| 'runtimeNotInstalled'
| 'conversationOpenInTerminal'
>
/** First-hand proof that acquisition spawned no process. */
@@ -95,6 +95,9 @@ export type AttachFlowInput = {
eventSink?: StructuredAgentSessionEventSink
/** Prevent acquisition-window events from reaching superseded journals. */
onAcquiring?: () => Promise<void> | void
/** Refuses the spawn while a terminal may still be writing the record's conversation. */
assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise<void>
/** Settles writes already captured by the superseded journal before opening another. */
beforeJournalOpen?: () => Promise<void> | void
/** Adopt the conversation’s journal; attach never owns its open. */
openConversation: (record: AgentSessionRecord) => Promise<AgentSessionJournal>
@@ -243,6 +243,9 @@ async function runAttachUnderAbort(
launchPermissionMode: async (record) =>
(await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ??
undefined,
assertNoTerminalWriter: async (record) => {
await lock.wait(Promise.resolve(context.deps.assertNoTerminalWriter?.(record)))
},
...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}),
openConversation: async (record) => {
const conversation = await context.openConversation(record.sessionId, { acquisition: true })
@@ -24,6 +24,7 @@ import {
isAgentSessionPreSpawnError
} from './structured-agent-session-adapter'
import {
startupAcquisitionRefusal,
providerExitObserved,
structuredAgentSessionStartFailure
} from './structured-agent-session-failure-text'
@@ -117,8 +118,9 @@ function failedAcquisitionDetails(
if (argumentProblem) {
return { reason: 'attachFailed', argumentProblem }
}
if (error instanceof AgentSessionAcquisitionRefusal) {
return { reason: error.reason, ...(error.account ? { account: error.account } : {}) }
const typed = startupAcquisitionRefusal(error)
if (typed) {
return { reason: typed.reason, ...(typed.account ? { account: typed.account } : {}) }
}
if (isAgentSessionPreSpawnError(error) && error.reason) {
return { reason: error.reason }
@@ -36,7 +36,9 @@ const TYPED_START_REFUSALS = [
'claudeAccountFolderMissing',
'claudeAccountSetupFailed',
'agentCommandNotRunnable',
'runtimeNotInstalled'
'runtimeNotInstalled',
'conversationOpenInTerminal',
'conversationOpenElsewhere'
] as const satisfies readonly (AgentSessionFailureKind &
AgentSessionRefusalReason<'agent_session_operation_invalid'>)[]
@@ -59,8 +61,28 @@ export function providerExitObserved(error: unknown): boolean {
)
}
/** Preserve typed/observed causes and diagnostics; unclassified spawn/host errors cannot imply provider failure. */
/** Cleanup wrappers retain the provider's typed refusal without changing their exit proof. */
export function startupAcquisitionRefusal(
error: unknown,
depth = 0
): AgentSessionAcquisitionRefusal | undefined {
if (depth >= 6 || !(error instanceof Error)) {
return undefined
}
if (error instanceof AgentSessionAcquisitionRefusal) {
return error
}
return (
startupAcquisitionRefusal(error.cause, depth + 1) ??
(error instanceof AggregateError
? error.errors.map((inner) => startupAcquisitionRefusal(inner, depth + 1)).find(Boolean)
: undefined)
)
}
/** A failed start keeps its typed refusal or observed exit and private provider diagnostic. */
export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact {
cause = startupAcquisitionRefusal(cause) ?? cause
const installation = agentCliInstallationProblemOf(cause)
if (installation) {
return agentSessionFailureFact('startFailed', {
@@ -143,7 +143,10 @@ export type StructuredAgentSessionHostDeps = {
hasOpenDispatch?: (record: AgentSessionRecord) => boolean
/** Advisory tab-hidden notice; failures are logged. */
onSessionTabHidden?: (sessionId: string) => void
/** Required to report failures that never reach the UI. */
/** Throws while a terminal may still write the record's head conversation; run before a spawn. */
assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise<void>
/** Where every failure the host carries on past is reported. Required: a host without one would
* drop exactly the failures nobody sees in the UI. */
logger: StructuredAgentSessionLogger
onRequestFailure?: (failure: StructuredAgentSessionRequestFailure) => void
/** replay distinguishes restored/projected state from fresh journal events. */
@@ -1,3 +1,4 @@
import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter'
import { expect, it, vi } from 'vitest'
import type {
StructuredAgentSessionProviderOpen,
@@ -10,7 +11,7 @@ import {
envelope,
hostTestState
} from './structured-agent-session-host-test-harness'
import { hostTestMessage } from './structured-agent-session-host-test-data'
import { HOST_TEST_SESSION, hostTestMessage } from './structured-agent-session-host-test-data'
const OPENED: StructuredAgentSessionProviderOpenResult = {
reportedOptions: { model: 'gpt-test' }
@@ -56,3 +57,24 @@ it('cancels the open on quit after a second client attach is refused', async ()
await ended
}
})
it('journals a typed writer refusal and cleans up the real starting child', async () => {
const { host, open, close, dispatch, pending, send } = awaitOpen()
await attach()
await send('continue C')
await vi.waitFor(() => expect(open).toHaveBeenCalledOnce())
pending.reject(
new AgentSessionAcquisitionRefusal(
'thread C already has an active writer',
'conversationOpenElsewhere'
)
)
await vi.waitFor(async () => {
const snapshot = await host.journalSnapshot(HOST_TEST_SESSION)
expect(
snapshot.submissions.some((entry) => entry.rejection?.kind === 'conversationOpenElsewhere')
).toBe(true)
})
expect(close).toHaveBeenCalledOnce()
expect(dispatch).not.toHaveBeenCalled()
})
@@ -0,0 +1,440 @@
import {
assertCliConversationWritersExited,
posixCliWriterShellAlone
} from '../../runtime/structured-agent-cli-writers'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
const physicalSnapshot = vi.hoisted(() => vi.fn())
vi.mock('../../../shared/process-table-snapshot-reader', async (importOriginal) => ({
...(await importOriginal<object>()),
getFreshShellForegroundSnapshot: physicalSnapshot
}))
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import {
closeTestJournalHostDatabase,
openTestJournalHostDatabase
} from '../agent-session-journal/journal-host-database-test-support'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import {
NO_STRUCTURED_AGENTS,
structuredAgentsDeclaringNothing
} from './structured-agent-session-adapter-router-test-support'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
import { structuredAgentRuntimeRegistration } from '../../runtime/structured-agent-runtime-registrations'
import { attachFingerprintFields } from './structured-agent-session-attach'
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
let root: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
let terminalWriting: boolean
const CALLER = { callerKey: 'client-1' }
const acquire = vi.fn<StructuredAgentSessionAdapter['acquire']>(async ({ fence, spawnToken }) => ({
process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken },
acquisitionGeneration: `generation-${fence}`,
link: {
linkId: `link-${fence}`,
handle: codexProviderHandle(THREAD),
origin: fence === 1 ? ('created' as const) : ('resumed' as const),
mintedAtFence: fence,
observedAt: NOW
}
}))
const assertNoTerminalWriter = vi.fn(async (_record: AgentSessionRecord) => {
if (terminalWriting) {
throw agentSessionRefusalError('agent_session_conflict', {
reason: 'conversationHeldElsewhere'
})
}
})
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-terminal-writer-acquire-'))
resetHostTestOperationIds()
terminalWriting = false
physicalSnapshot.mockReset()
acquire.mockClear()
assertNoTerminalWriter.mockReset().mockImplementation(async () => {
if (terminalWriting) {
throw agentSessionRefusalError('agent_session_conflict', {
reason: 'conversationHeldElsewhere'
})
}
})
const openCode = structuredAgentRuntimeRegistration('opencode')
if (!openCode) {
throw new Error('OpenCode is not registered')
}
store = await openTestAgentSessionRecordStore(root)
host = new StructuredAgentSessionHost({
agents: structuredAgentsDeclaringNothing([
...NO_STRUCTURED_AGENTS.definitions(),
openCode.definition
]),
logger: createStructuredAgentSessionLogger(),
store,
adapter: {
acquire,
dispatch: vi.fn(async () => ({
state: 'accepted' as const,
providerIdentity: { provider: 'codex' as const, threadId: THREAD, turnId: 't', ordinal: 1 }
})),
closeSession: vi.fn(async () => true),
releaseAcquisition: vi.fn(async () => true),
cancelTurn: vi.fn(async () => ({ cancelled: false })),
answerPrompt: vi.fn(async () => undefined),
setOption: vi.fn(async () => undefined),
open: async () => ({ reportedOptions: { model: '' } })
},
journalDatabase: openTestJournalHostDatabase(root),
claimKeyId: 'key-1',
mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`,
assertNoTerminalWriter,
now: () => NOW
})
expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true })
})
afterEach(async () => {
await host.flushAllStreamedEvents()
closeTestJournalHostDatabase(root)
await rm(root, { recursive: true, force: true })
})
async function send(text: string, sessionId = SESSION): Promise<string> {
const body = hostTestMessage(text)
const envelope = {
sessionId,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: 1,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId,
fields: { body }
})
}
expect(await host.send(CALLER, { envelope, body })).toMatchObject({ ok: true })
return envelope.clientOperationId
}
async function rejection(id: string, sessionId = SESSION) {
await vi.waitFor(
async () =>
expect(
(await host.journalSnapshot(sessionId)).submissions.find((s) => s.clientMessageId === id)
?.dispatchState
).toBe('rejected'),
{ timeout: 10_000 }
)
return (await host.journalSnapshot(sessionId)).submissions.find((s) => s.clientMessageId === id)
}
// H10: a chat reopened while its conversation runs in a terminal starts no second writer.
it('refuses the chat agent start while a terminal writes its conversation, once per send', async () => {
await host.close(SESSION, 'evict')
terminalWriting = true
await host.revealSession(SESSION)
const spawnsBefore = acquire.mock.calls.length
assertNoTerminalWriter.mockClear()
const first = await rejection(await send('first'))
expect(first?.rejection).toMatchObject({ kind: 'conversationOpenInTerminal' })
expect(first?.reason).toBe(
'Codex is already running this conversation in another terminal. Quit Codex there or close that terminal to continue here.'
)
expect(acquire.mock.calls.length).toBe(spawnsBefore)
expect(store.getRecord(SESSION)?.lease.reservedSpawnToken).toBeNull()
await rejection(await send('second'))
expect(assertNoTerminalWriter).toHaveBeenCalledTimes(2)
expect(acquire.mock.calls.length).toBe(spawnsBefore)
terminalWriting = false
await send('after the terminal exits')
await vi.waitFor(() => expect(acquire.mock.calls.length).toBe(spawnsBefore + 1), {
timeout: 10_000
})
})
it('acquires when only a saved Sleep note remains, but refuses an existing unknown terminal', async () => {
const saved: SleepingAgentSessionRecord = {
paneKey: 'slept',
worktreeId: 'workspace-1',
agent: 'codex',
providerSession: { key: 'session_id', id: THREAD },
prompt: '',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
}
let terminalExists = true
assertNoTerminalWriter.mockImplementation((record) =>
assertCliConversationWritersExited(
{
saved: () => [saved],
rows: () => [],
links: () => [],
observation: () => null,
sleeping: () => true,
terminal: () => (terminalExists ? { pane: null } : null),
shellAlone: vi.fn(async () => false)
},
record.provider,
record.providerHandleChain.at(-1)?.handle.nativeId ?? ''
)
)
await host.close(SESSION, 'evict')
await host.revealSession(SESSION)
const before = acquire.mock.calls.length
expect((await rejection(await send('unknown physical terminal')))?.rejection).toMatchObject({
kind: 'conversationOpenInTerminal'
})
expect(acquire).toHaveBeenCalledTimes(before)
terminalExists = false
const id = await send('saved note only')
await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(before + 1))
await vi.waitFor(async () =>
expect(
(await host.journalSnapshot(SESSION)).submissions.find((s) => s.clientMessageId === id)
?.dispatchState
).toBe('accepted')
)
})
it('preserves the saved OpenCode guard through acquisition and the recorded send', async () => {
const sessionId = `${SESSION}-opencode`
const definition = structuredAgentRuntimeRegistration('opencode')?.definition
if (!definition) {
throw new Error('OpenCode is not registered')
}
const handle = { transport: definition.handleTransport, agent: 'opencode', nativeId: THREAD }
acquire.mockImplementationOnce(async ({ fence, spawnToken }) => ({
process: { hostId: 'local', pid: 4243, processStartTimeMs: NOW, spawnToken },
acquisitionGeneration: `generation-${fence}`,
link: {
linkId: `link-${fence}`,
handle,
origin: 'created',
mintedAtFence: fence,
observedAt: NOW
}
}))
const params = hostTestAttachParams(null, {
provider: 'opencode',
agent: 'opencode',
accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } },
providerHandle: undefined
})
params.envelope.sessionId = sessionId
params.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId,
fields: attachFingerprintFields(params)
})
const attached = await host.attach(CALLER, params)
expect(attached).toMatchObject({ ok: true })
await host.close(sessionId, 'evict')
const saved: SleepingAgentSessionRecord = {
paneKey: 'slept',
worktreeId: 'workspace-1',
agent: 'opencode',
providerSession: { key: 'session_id', id: THREAD },
prompt: '',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
}
assertNoTerminalWriter.mockImplementation((record) =>
assertCliConversationWritersExited(
{
saved: () => [saved],
rows: () => [],
links: () => [],
observation: () => null,
sleeping: () => true,
terminal: () => null,
shellAlone: vi.fn(async () => false)
},
record.provider,
record.providerHandleChain.at(-1)?.handle.nativeId ?? ''
)
)
await host.revealSession(sessionId)
const before = acquire.mock.calls.length
const rejected = await rejection(await send('resume saved OpenCode', sessionId), sessionId)
expect(rejected?.rejection).toMatchObject({ kind: 'conversationOpenInTerminal' })
expect(rejected?.reason).toBe(
'OpenCode is already running this conversation in another terminal. Quit OpenCode there or close that terminal to continue here.'
)
expect(acquire).toHaveBeenCalledTimes(before)
expect(store.getRecord(sessionId)?.lease.reservedSpawnToken).toBeNull()
})
it.each([10, 100])(
'drives acquisition through the real census for %i matching/unrelated panes',
async (size) => {
let enumerated = 0
const saved: Record<string, SleepingAgentSessionRecord> = Object.fromEntries(
Array.from({ length: size }, (_, index) => [
`pane-${index}`,
{
paneKey: `pane-${index}`,
worktreeId: 'workspace-1',
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: THREAD },
state: 'done' as const,
prompt: '',
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
])
)
const population = new Proxy(saved, {
ownKeys(target) {
const keys = Reflect.ownKeys(target)
enumerated += keys.length
return keys
}
})
let rowVisits = 0
let linkVisits = 0
const rows = Array.from({ length: size }, (_, index) => ({
paneKey: `unrelated-${index}`,
connectionId: null,
agentType: 'codex',
providerSession: { key: 'session_id' as const, id: 'unrelated' },
state: 'done' as const,
prompt: '',
receivedAt: 1,
stateStartedAt: 1
}))
const links = rows.map(() => ({
agent: 'codex',
id: 'unrelated',
paneOwned: true,
worktreeId: 'workspace-1',
terminal: () => null
}))
rows.forEach((row, index) =>
Object.defineProperty(rows, index, {
configurable: true,
enumerable: true,
get: () => {
rowVisits += 1
return row
}
})
)
links.forEach((link, index) =>
Object.defineProperty(links, index, {
configurable: true,
enumerable: true,
get: () => {
linkVisits += 1
return link
}
})
)
rowVisits = 0
const shell = [{ pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }]
physicalSnapshot.mockResolvedValue(shell)
const physical = vi.fn(() => posixCliWriterShellAlone(10))
assertNoTerminalWriter.mockImplementation((record) =>
assertCliConversationWritersExited(
{
saved: () => Object.values(population),
rows: () => rows,
links: () => links,
observation: () => null,
sleeping: () => false,
terminal: () => ({ pane: { ptyId: 'shell', incarnationId: 'i1', identity: 'shell:i1' } }),
shellAlone: physical
},
record.provider,
record.providerHandleChain.at(-1)?.handle.nativeId ?? ''
)
)
const start = async () => {
await host.close(SESSION, 'evict')
await host.revealSession(SESSION)
const before = acquire.mock.calls.length
await send('acquire after census')
await vi.waitFor(() => expect(acquire.mock.calls.length).toBe(before + 1))
}
for (const record of Object.values(saved)) {
record.providerSession.id = 'unrelated'
}
await start()
expect([enumerated, physical.mock.calls.length]).toEqual([2 * size, 0])
expect([rowVisits, linkVisits]).toEqual([size, size])
for (const record of Object.values(saved)) {
record.providerSession.id = THREAD
}
enumerated = 0
rowVisits = 0
linkVisits = 0
await start()
// Across each physical await, existing saved-membership and final coverage revalidate the population.
expect(enumerated).toBe((6 * size + 2) * size)
expect([rowVisits, linkVisits]).toEqual([(4 * size + 1) * size, size])
expect(physical).toHaveBeenCalledTimes(size)
expect(physicalSnapshot).toHaveBeenCalledTimes(size)
await host.close(SESSION, 'evict')
await host.revealSession(SESSION)
physicalSnapshot.mockResolvedValue([
...shell,
{ pid: 11, ppid: 10, stat: 'T', command: 'codex' }
])
physical.mockClear()
const before = acquire.mock.calls.length
await rejection(await send('live terminal witness'))
expect(acquire).toHaveBeenCalledTimes(before)
expect(physical).toHaveBeenCalledTimes(1)
let finish = () => {}
physicalSnapshot.mockResolvedValue(shell)
physicalSnapshot.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = () => resolve(shell)
})
)
physical.mockClear()
await send('cancel during physical proof')
await vi.waitFor(() => expect(physical).toHaveBeenCalledTimes(1))
const closed = host.close(SESSION, 'evict')
finish()
await closed
await assertNoTerminalWriter.mock.results.at(-1)?.value
// Closing prevents the late spawn; this inherited census still finishes every physical proof.
expect(physical).toHaveBeenCalledTimes(size)
expect(acquire).toHaveBeenCalledTimes(before)
physicalSnapshot.mockResolvedValue(shell)
enumerated = 0
await host.revealSession(SESSION)
await send('successful successor')
await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(before + 1))
expect(enumerated).toBeLessThanOrEqual((6 * size + 2) * size)
}
)
@@ -1,6 +1,12 @@
import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication'
import { structuredAgentRuntimeMetadata } from '../../runtime/structured-agent-runtime-metadata'
import {
keysMayMatch,
recordConversationKey,
type ConversationKey
} from '../structured-conversation-key'
export type StructuredProviderSessionOwnership = {
sessionId: string
@@ -9,6 +15,9 @@ export type StructuredProviderSessionOwnership = {
providerSessionId: string
conversationName?: string
lease: AgentSessionLease
/** `head`: the conversation the chat continues now; `earlier`: one its chain held before. */
role: 'head' | 'earlier'
key: ConversationKey
}
/** Use registration-defined History row IDs for both ownership display and duplicate-adoption checks. */
@@ -17,15 +26,45 @@ export function listStructuredProviderSessionOwnership(
): StructuredProviderSessionOwnership[] {
return records.flatMap((record) => {
const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory
return history
? record.providerHandleChain.map((link) => ({
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
provider: record.provider,
providerSessionId: history.rowSessionId(link),
conversationName: record.conversationName,
lease: record.lease
}))
const head = record.providerHandleChain.at(-1)
const headKey = head ? recordConversationKey(record, head) : null
return history && headKey
? record.providerHandleChain.flatMap((link) => {
const key = recordConversationKey(record, link)
return key
? [
{
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
provider: record.provider,
providerSessionId: history.rowSessionId(link),
conversationName: record.conversationName,
lease: record.lease,
// A repeat resume link of the head's id is the head too.
role: keysMayMatch(key, headKey) ? ('head' as const) : ('earlier' as const),
key
}
]
: []
})
: []
})
}
/** Fail closed: only a lease released on proof its owner exited can't be writing. */
export function agentSessionLeasePossiblyWriting(lease: AgentSessionLease): boolean {
return agentSessionLeaseOwnerVerdict(lease) !== 'exited'
}
/** Agents whose chats let a terminal resume the same conversation once the chat stopped writing. */
export function sameIdTerminalResumeEnabled(_provider: StructuredAgentId): boolean {
return false
}
/** Whether a terminal resume of this link's conversation is refused. */
export function terminalResumeBlocked(holding: StructuredProviderSessionOwnership): boolean {
return (
!sameIdTerminalResumeEnabled(holding.provider) ||
(holding.role === 'head' && agentSessionLeasePossiblyWriting(holding.lease))
)
}
@@ -64,12 +64,13 @@ function ownership(
provider: 'codex',
providerSessionId: 'thread-1',
lease: agentSessionLeaseFixture(),
role: 'head',
...overrides
}
}
describe('findConflictingStructuredAdoption', () => {
it('names the session that already holds the conversation', () => {
it('names the session continuing the conversation now, never one that held it earlier', () => {
const owner = ownership()
expect(
@@ -77,7 +78,11 @@ describe('findConflictingStructuredAdoption', () => {
agent: 'codex',
providerSessionId: 'thread-1',
selfSessionId: 'codex_new',
ownership: [ownership({ sessionId: 'other', providerSessionId: 'thread-2' }), owner]
ownership: [
ownership({ sessionId: 'other', providerSessionId: 'thread-2' }),
ownership({ sessionId: 'moved_on', role: 'earlier' }),
owner
]
})
).toBe(owner)
})
@@ -22,6 +22,7 @@ export type StructuredAgentSessionAdoptionOwnership = {
provider: StructuredAgentId
providerSessionId: string
lease: AgentSessionLease
role: 'head' | 'earlier'
}
export type StructuredAgentSessionAdoption = {
@@ -83,6 +84,7 @@ export function findConflictingStructuredAdoption(input: {
input.ownership.find(
(owner) =>
owner.sessionId !== input.selfSessionId &&
owner.role === 'head' &&
chatAgentsShareConversations(owner.provider, input.agent) &&
owner.providerSessionId === input.providerSessionId
) ?? null
@@ -0,0 +1,66 @@
import {
chatAgentsShareConversations,
CLI_CONVERSATION_ADOPTING_AGENTS
} from '../../shared/structured-agent-cli-conversation-adoption'
import {
agentSessionAccountHomesEqual,
type AgentSessionAccountHome
} from '../../shared/agent-session-account-home'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { structuredAgentRuntimeMetadata } from '../runtime/structured-agent-runtime-metadata'
import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator'
/** One conversation wherever it is named: the agents sharing its ids, its id (null: a targetless
* resume that may pick any of the family's), and its account home (null: the namer can't tell). */
export type ConversationKey = {
family: string
id: string | null
home: AgentSessionAccountHome | null
}
const FAMILY_MEMBERS = [...new Set(Object.entries(CLI_CONVERSATION_ADOPTING_AGENTS).flat(2))].sort()
/** Agents that open one conversation's ids (`opencode`, `opencode2`) share one family name. */
export function conversationFamily(agent: string): string {
return FAMILY_MEMBERS.find((member) => chatAgentsShareConversations(member, agent)) ?? agent
}
export function recordConversationKey(
record: AgentSessionRecord,
link: AgentSessionProviderHandleLink
): ConversationKey | null {
const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory
return history
? {
family: conversationFamily(record.provider),
id: history.rowSessionId(link),
home: record.accountHome
}
: null
}
/** A terminal names a conversation by the id its hooks or command carry; its home is unknown. */
export function terminalConversationKey(agent: string, id: string | null): ConversationKey {
return { family: conversationFamily(agent), id: id?.trim() || null, home: null }
}
export function keysMayMatch(left: ConversationKey, right: ConversationKey): boolean {
return (
left.family === right.family &&
(left.id === null || right.id === null || sameConversationId(left.family, left.id, right.id)) &&
(left.home === null ||
right.home === null ||
agentSessionAccountHomesEqual(left.home, right.home))
)
}
// A session-file agent's terminal may name the file; Session History lists its file-name id.
function sameConversationId(family: string, left: string, right: string): boolean {
if (family === 'opencode') {
return left === right
}
const normalize = (id: string) =>
(/[\\/]/.test(id) ? sessionIdFromFileName(id) : id).toLowerCase()
return normalize(left) === normalize(right)
}
+2 -1
View File
@@ -1,3 +1,4 @@
import type { TerminalLaunchResult } from '../../shared/agent-session-resume'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { TuiAgent } from '../../shared/tui-agent'
import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
@@ -5,7 +6,7 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { TerminalOwner } from '../../shared/terminal-owner'
import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
export type PtySpawnResult = {
export type PtySpawnResult = TerminalLaunchResult & {
/** Execution-host probe; absent on older hosts and reattachments. */
openCodeCapabilities?: OpenCodeCliCapabilities
agentSessionEnsure?: AgentSessionClaimedSpawnResult
@@ -1,6 +1,9 @@
// Launch admission opens the single-writer store before chat-host installation; both must reuse this instance.
// Quit-only edge: failed host teardown can leave its connection open after releasing the slot; a post-stop open lasts until exit.
import { existsSync } from 'node:fs'
import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database'
import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database'
import { AgentSessionRecordStore } from './agent-session-record-store'
import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open'
@@ -81,3 +84,20 @@ export async function releaseAgentSessionRecordStore(): Promise<OpenedAgentSessi
opening = null
return released ? released.opened.catch(() => null) : null
}
/** Whether this profile holds a structured chat: a record or tab in the journal database. */
export function hasPersistedStructuredAgentSessionStore(
stateDirectory: string,
fileExists: (path: string) => boolean = existsSync
): boolean {
const databasePath = journalDatabasePath(stateDirectory)
if (!fileExists(databasePath)) {
return false
}
try {
return journalDatabaseHoldsAgentSessions(databasePath)
} catch {
// A database that cannot be read cannot say it is empty.
return true
}
}
@@ -138,6 +138,29 @@ describe('adopted conversation ownership', () => {
).not.toThrow()
})
it('admits a conversation another record only held before its current one', () => {
const fixture = agentSessionRecordFixture()
const holder: AgentSessionRecord = {
...fixture,
providerHandleChain: [
...fixture.providerHandleChain,
adoptedLink({
linkId: 'later',
handle: claudeProviderHandle('provider-session-later', null),
origin: 'created'
})
]
}
expect(() =>
applyAgentSessionReservation(
storeState([holder]),
reserveRequest({ adoptedHandleLink: adoptedLink() }),
LEASE_TTL_MS
)
).not.toThrow()
})
it('preserves the adopted chain and launch pin when retrying an existing reservation', () => {
// Pin self-exemption directly; create-by-intent has null expectedFence and rejects existing ids before this can act as replay.
const link = adoptedLink()
@@ -210,7 +210,7 @@ export function applyAgentSessionReservation(
})
}
/** Refuse conversations held by other records, including other branches of their root.
/** Refuse another record's current conversation, matching the pre-commit check.
* Run inside the transaction: distinct adoption ids evade per-record CAS and providers may permit corrupting concurrent writers.
* Self-exemption is not replay; the operation ledger resolves replay before this guard. */
export function assertAdoptedConversationUnowned(
@@ -226,14 +226,15 @@ export function assertAdoptedConversationUnowned(
if (record.sessionId === request.sessionId) {
continue
}
const holdsSameConversation = record.providerHandleChain.some(
(link) =>
agentSessionProviderHandleRoot(link.handle) === root ||
// Only the conversation a chat continues now: an earlier link no longer holds anything.
const head = record.providerHandleChain.at(-1)
const holdsSameConversation =
!!head &&
(agentSessionProviderHandleRoot(head.handle) === root ||
// One id from two chat agents that open the same conversations (`opencode`, `opencode2`).
(link.handle.transport === adopted.handle.transport &&
link.handle.nativeId === adopted.handle.nativeId &&
chatAgentsShareConversations(link.handle.agent, adopted.handle.agent))
)
(head.handle.transport === adopted.handle.transport &&
head.handle.nativeId === adopted.handle.nativeId &&
chatAgentsShareConversations(head.handle.agent, adopted.handle.agent)))
if (holdsSameConversation) {
throw agentSessionRefusalError('agent_session_conflict', {
reason: 'conversationHeldElsewhere',
@@ -1,6 +1,17 @@
/** Host creates need durable binding/runtime ownership to survive graph sync without a renderer writer. */
import { describe, expect, it, vi } from 'vitest'
import './rpc/unused-default-rpc-methods.test-fixture'
import {
startDesktopMultiplexSubscribe,
sendDesktopMultiplexSubscribe
} from './rpc/terminal-multiplex-test-harness'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { OrcaRuntimeService } from './orca-runtime'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamText
} from '../../shared/terminal-stream-protocol'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) },
@@ -68,3 +79,245 @@ describe('host-initiated terminal creation under an attached window', () => {
expect(pty?.runtimeSessionOwned).toBe(true)
})
})
it.each(['refused', 'adopted', 'stable-adopted', 'ensure-adopted', 'fresh-unavailable'] as const)(
'publishes the effective %s terminal rather than the requested saved recipe',
async (kind) => {
const { runtime, spawn } = createRuntimeWithAttachedWindow()
const config = { agentCommand: 'codex', agentArgs: '', agentEnv: {} }
const adopted = kind === 'adopted' || kind === 'stable-adopted' || kind === 'ensure-adopted'
const ownerSurface = {
worktreeId: 'repo-1::/tmp/wt-cli',
terminalHandle: 'owner-handle',
tabId: 'owner-tab',
leafId: '11111111-1111-4111-8111-111111111111'
}
if (adopted && kind !== 'adopted') {
// An adopted surface is registered; a bare provider reattach need not be.
runtime.registerPty('effective-pty', ownerSurface.worktreeId, null, {
tabId: ownerSurface.tabId,
leafId: ownerSurface.leafId,
incarnationId: 'physical-i1',
terminalHandle: ownerSurface.terminalHandle
})
}
const coldRestore = { scrollback: 'checkpoint A', cwd: '/tmp/wt-cli', cols: 80, rows: 24 }
spawn.mockResolvedValue({
id: 'effective-pty',
pid: 42,
incarnationId: 'physical-i1',
coldRestore,
snapshot: 'current image B',
snapshotCols: 80,
snapshotRows: 24,
snapshotSeq: 7,
providerSequence: { value: 7, generation: 'continued' },
attachedGrid: { cols: 80, rows: 24 },
isAlternateScreen: false,
replay: 'provider backlog',
shellReadyArmed: true,
openCodeCapabilities: { version: '2.0.16', pluginApi: 'v2', promptMode: 'prefill' },
...(adopted
? { launchAgent: 'codex', launchConfig: config, launchToken: 'owner-token' }
: {}),
...(kind === 'adopted' ? { isReattach: true } : {}),
...(kind === 'stable-adopted'
? {
stablePaneOwner: {
handle: ownerSurface.terminalHandle,
tabId: ownerSurface.tabId,
leafId: ownerSurface.leafId
}
}
: {}),
...(kind === 'ensure-adopted'
? {
agentSessionEnsure: {
disposition: 'adopted',
owner: {
claim: {
digestVersion: 1,
keyId: 'key',
identityDigest: 'C',
worktreeScopeDigest: 'wt',
agent: 'codex'
},
generation: 'owner-generation',
phase: 'live',
ptyId: 'effective-pty',
surface: ownerSurface
}
}
}
: {}),
...(kind === 'refused'
? {
agentResumeRefused: 'chat-home',
agentResumeUnavailable: true,
savedResumeDecision: {
agent: 'claude',
providerSession: { key: 'session_id', id: 'C' },
verdict: 'chat-home'
}
}
: {}),
...(kind === 'fresh-unavailable'
? {
agentResumeUnavailable: true,
launchAgent: 'codex',
launchConfig: config,
launchToken: 'actual-fresh-token'
}
: {})
})
const result = await runtime.createTerminal('id:repo-1::/tmp/wt-cli', {
launchAgent: 'claude',
title: 'Rejected C',
resumeOrigin: 'saved',
resumeProviderSession: { key: 'session_id', id: 'C' },
launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }
})
expect(result).toEqual({
handle: expect.any(String),
tabId: expect.any(String),
paneKey: expect.any(String),
ptyId: 'effective-pty',
savedStartupCwd: '/tmp/wt-cli',
worktreeId: 'repo-1::/tmp/wt-cli',
title: kind === 'fresh-unavailable' ? 'Rejected C' : null,
launchConfig: kind === 'refused' ? undefined : config,
launchAgent: kind === 'refused' ? undefined : 'codex',
launchToken: kind === 'refused' ? undefined : adopted ? 'owner-token' : 'actual-fresh-token',
executionHostId: 'local',
hostPlatform: process.platform,
incarnationId: 'physical-i1',
surface: 'background',
processId: 42,
...(adopted ? { isReattach: true } : { coldRestore }),
...(kind === 'ensure-adopted' ? { agentSessionDisposition: 'adopted' } : {}),
...(!adopted ? { agentResumeUnavailable: true } : {}),
...(kind === 'refused'
? {
agentResumeRefused: 'chat-home',
savedResumeDecision: {
agent: 'claude',
providerSession: { key: 'session_id', id: 'C' },
verdict: 'chat-home'
}
}
: {}),
warning: expect.any(String)
})
expect(runtime.resolveTerminalPane(result.paneKey ?? '', result.worktreeId)).toMatchObject({
handle: result.handle,
ptyId: 'effective-pty',
connected: true
})
expect(result.agentResumeRefused).toBe(kind === 'refused' ? 'chat-home' : undefined)
expect(result.launchAgent).toBe(kind === 'refused' ? undefined : 'codex')
expect(result.launchConfig).toEqual(kind === 'refused' ? undefined : config)
if (kind === 'refused') {
expect(result.title).not.toBe('Rejected C')
}
if (kind === 'fresh-unavailable') {
expect(result.launchToken).toBe('actual-fresh-token')
}
}
)
it.each(['unchanged', 'hook', 'config', 'incarnation', 'handle'] as const)(
'samples the actual created PTY after snapshot awaits and revalidates %s on reconnect',
async (change) => {
const { runtime, spawn } = createRuntimeWithAttachedWindow()
const providerSession = { key: 'session_id' as const, id: 'C' }
spawn.mockResolvedValue({
id: 'effective-pty',
incarnationId: 'i1',
agentResumeUnavailable: true,
agentResumeRefused: 'chat-home',
savedResumeDecision: { agent: 'claude', providerSession, verdict: 'chat-home' }
})
const terminal = await runtime.createTerminal('id:repo-1::/tmp/wt-cli', {
tabId: 'saved-tab',
leafId: '11111111-1111-4111-8111-111111111111',
launchAgent: 'claude',
resumeOrigin: 'saved',
resumeProviderSession: providerSession
})
let snapshotStarted = false
let finishSnapshot = () => {}
let handle = terminal.handle
const harness = startDesktopMultiplexSubscribe({
resolveLiveLeafForHandle: () => runtime.resolveLiveLeafForHandle(handle),
getPtyExecutionHostMetadata: runtime.getPtyExecutionHostMetadata.bind(runtime),
getTerminalLaunchResult: runtime.getTerminalLaunchResult.bind(runtime),
serializeTerminalBuffer: () =>
new Promise((resolve) => {
snapshotStarted = true
finishSnapshot = () => resolve({ data: 'shell$ ', cols: 80, rows: 24 })
})
})
try {
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() => expect(snapshotStarted).toBe(true))
if (change === 'hook') {
runtime.recordCliWriterConversation({
paneKey: 'saved-tab:11111111-1111-4111-8111-111111111111',
connectionId: null,
receivedAt: 1,
stateStartedAt: 1,
providerSession,
payload: { state: 'done', prompt: '', agentType: 'claude' },
agentPresence: {
agent: 'claude',
process: { pid: 12, platform: 'darwin', startTime: 's' }
}
})
} else if (change === 'config') {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime owns this PTY map; mutate its actual record to model a replacement launch.
const records = (runtime as unknown as { ptysById: Map<string, RuntimePtyWorktreeRecord> })
.ptysById
const pty = records.get('effective-pty')!
pty.launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} }
} else if (change === 'incarnation') {
runtime.registerPty('effective-pty', 'repo-1::/tmp/wt-cli', null, {
tabId: 'saved-tab',
leafId: '11111111-1111-4111-8111-111111111111',
incarnationId: 'i2'
})
} else if (change === 'handle') {
handle = 'stale-handle'
}
finishSnapshot()
const events = () => harness.messages.map((message) => JSON.parse(message).result)
const stale = change === 'handle' || change === 'incarnation'
await vi.waitFor(() =>
expect(events().some((event) => event?.type === (stale ? 'end' : 'subscribed'))).toBe(true)
)
if (stale) {
expect(events().some((event) => event?.type === 'subscribed')).toBe(false)
const error = harness.binaryFrames
.map(decodeTerminalStreamFrame)
.find((frame) => frame?.opcode === TerminalStreamOpcode.Error)
expect(error && decodeTerminalStreamText(error.payload)).toBe('terminal_handle_stale')
} else {
const result = events().find((event) => event?.type === 'subscribed').launchResult
expect(result).toMatchObject({ ptyId: 'effective-pty', incarnationId: 'i1' })
expect(result.agentResumeRefused).toBe(change === 'unchanged' ? 'chat-home' : undefined)
harness.messages.splice(0)
snapshotStarted = false
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() => expect(snapshotStarted).toBe(true))
finishSnapshot()
await vi.waitFor(() =>
expect(events().some((event) => event?.type === 'subscribed')).toBe(true)
)
expect(events().find((event) => event?.type === 'subscribed').launchResult).toEqual(result)
}
} finally {
harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
await harness.dispatchPromise
}
}
)
@@ -161,3 +161,115 @@ describe('a queued hook status and a workspace sleep', () => {
})
})
})
it.each([false, true])(
'orders refused cleanup after queued status and preserves newer same-conversation recipe (%s)',
async (newer) => {
const { runtime, getSession, releaseFirstWrite } = makeRuntimeWithSlowFirstWrite()
runtime.recordHookStatusResume(codexRow())
await vi.waitFor(() =>
expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeDefined()
)
runtime.recordHookStatusResume(codexRow({ state: 'blocked', receivedAt: 11 }))
const sourcePromise = runtime.readSavedResumeSource(TEST_WORKTREE_ID, PANE_KEY)
releaseFirstWrite()
const source = await sourcePromise
if (!source) {
throw new Error('missing captured source')
}
const refused = {
agent: 'codex' as const,
providerSession: SESSION,
expectedStatus: { paneKey: PANE_KEY, receivedAt: 11, stateStartedAt: 10 },
expectedSavedRecord: {
paneKey: PANE_KEY,
capturedAt: source.capturedAt,
updatedAt: source.updatedAt
}
}
if (newer) {
runtime.recordHookStatusResume(codexRow({ receivedAt: 30, prompt: 'new explicit C' }))
}
await runtime.forgetRefusedSavedResume(TEST_WORKTREE_ID, PANE_KEY, refused, source)
await runtime['hookStatusResumeWrites']
const record = getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]
if (newer) {
expect(record).toMatchObject({ prompt: 'new explicit C', updatedAt: 30 })
} else {
expect(getSession().sleepingAgentSessionsByPaneKey).toEqual({})
}
}
)
it.each([10, 100])(
'counts actual refused cleanup copies/writes over %i saved panes',
async (size) => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const source = sleepCapture(11).get(PTY_ID)!.records[0]!
session.sleepingAgentSessionsByPaneKey = Object.fromEntries(
Array.from({ length: size }, (_, index) => [
`pane-${index}`,
{ ...source, paneKey: `pane-${index}` }
])
)
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore)
const originalClone = globalThis.structuredClone
let copied = 0
let clones = 0
const clone = vi.spyOn(globalThis, 'structuredClone').mockImplementation((value, options) => {
if (value && typeof value === 'object' && 'sleepingAgentSessionsByPaneKey' in value) {
copied += Object.keys(value.sleepingAgentSessionsByPaneKey ?? {}).length
clones += 1
}
return originalClone(value, options)
})
const clear = (index: number, stale = false) => {
const paneKey = `pane-${index}`
const record = { ...source, paneKey }
return runtime.forgetRefusedSavedResume(
TEST_WORKTREE_ID,
paneKey,
{
agent: 'codex',
providerSession: SESSION,
expectedSavedRecord: {
paneKey,
capturedAt: stale ? -1 : record.capturedAt,
updatedAt: record.updatedAt
}
},
record
)
}
try {
await clear(0, true)
expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([1, size, 0])
// The durable fixture executes the real mutation/rollback, failing only its disk-write primitive.
runtimeStore.flushOrThrow.mockImplementationOnce(() => {
throw new Error('disk full')
})
await expect(clear(0)).rejects.toThrow('disk full')
expect(getSession().sleepingAgentSessionsByPaneKey?.['pane-0']).toBeDefined()
clones = 0
copied = 0
runtimeStore.flushOrThrow.mockClear()
await Promise.all(Array.from({ length: size }, (_, index) => clear(index)))
// Each accepted edit clones before deleting, then stages one smaller map: sum(2k-1)=N².
expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([
2 * size,
size * size,
size
])
expect(getSession().sleepingAgentSessionsByPaneKey).toEqual({})
await clear(0)
expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([
2 * size + 1,
size * size,
size
])
} finally {
clone.mockRestore()
}
}
)
@@ -135,6 +135,7 @@ describe('host-driven worktree sleep keeps the slept agent resumable', () => {
expect(spawnCommand(spawn, 1)).toContain('--dangerously-skip-permissions')
expect(spawnCommand(spawn, 1)).toContain(SESSION_ID)
expect(spawn.mock.calls[1]?.[0]).toHaveProperty('resumeOrigin', 'saved')
})
it.each([
@@ -11,9 +11,20 @@ import {
makeWorkspaceSessionWithHeadlessTerminal
} from '../orca-runtime-test-fixtures.spec'
import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types'
import type { TerminalLaunchResult } from '../../../shared/agent-session-resume'
import type { PtyProcessInfo } from '../../providers/pty-provider-contract'
import { deterministicAgentSessionUuid } from '../runtime-agent-launch-resolution'
import { deriveRemoteRuntimeTerminalCreateHandle } from '../remote-runtime-terminal-create-identity'
import type { RuntimePtyController } from '../runtime-pty-controller-contract'
import { AgentSessionRecordStore } from '../agent-session-record-store'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import {
chooseSavedResumeFreshLaunch,
savedResumeResult
} from '../../ipc/pty/host-env/saved-resume-admission'
const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}`
const SESSION = { key: 'session_id', id: 'provider-session-1' } as const
@@ -72,7 +83,7 @@ function sessionRow(paneKey: string, receivedAt = 0): AgentStatusIpcPayload {
function makeRuntime(
fixture: Fixture,
opts: {
spawn?: () => Promise<{ id: string }>
spawn?: NonNullable<RuntimePtyController['spawn']>
rows?: () => AgentStatusIpcPayload[]
checkHookAgentPresence?: () => Promise<'live' | 'unverifiable' | 'exited' | null>
listProcesses?: () => Promise<PtyProcessInfo[]>
@@ -81,7 +92,7 @@ function makeRuntime(
) => () => void
} = {}
) {
const spawn = vi.fn<(args: unknown) => Promise<{ id: string }>>(
const spawn = vi.fn<NonNullable<RuntimePtyController['spawn']>>(
opts.spawn ?? (() => Promise.resolve({ id: 'resumed-pty' }))
)
const runtime = new OrcaRuntimeService(fixture.runtimeStore, undefined, {
@@ -147,6 +158,135 @@ function storedRecord(fixture: Fixture) {
}
describe('resumeAgentSessionInWorkspace', () => {
it.each(['live', 'worktree-sleep'] as const)(
'releases a %s saved resume claim when actual admission creates a plain shell',
async (origin) => {
const fixture = makeSleptWorkspace()
const saved = storedRecord(fixture)
if (!saved) {
throw new Error('missing saved conversation')
}
saved.origin = origin
const chat = agentSessionRecordFixture(
agentSessionLeaseFixture({
claimStatus: 'released',
ownerProcess: null,
reservedSpawnToken: null,
deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
})
)
chat.providerHandleChain[0]!.handle.nativeId = SESSION.id
let nativeOpen = true
const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
store.listRecords = () => [chat]
store.getVisibleSessionTabIndex = () => ({
present: true,
sessionIds: nativeOpen ? [chat.sessionId] : []
})
const effectiveCommands: (string | undefined)[] = []
const { runtime, spawn, openOldPane, resume } = makeRuntime(fixture, {
spawn: async (args) => {
const admission = await chooseSavedResumeFreshLaunch(
{ openAgentSessionRecordStore: async () => store },
args,
null,
args
)
effectiveCommands.push(args.command)
return {
id: effectiveCommands.length === 1 ? 'refused-shell' : 'resumed-C',
launchAgent: args.launchAgent,
launchConfig: args.launchConfig,
...savedResumeResult(admission)
}
}
})
await openOldPane()
expect(runtime.getTerminalLaunchResult('refused-shell')).toMatchObject({
agentResumeRefused: 'chat-home',
savedResumeDecision: { agent: 'claude', providerSession: SESSION, verdict: 'chat-home' }
})
expect(runtime.getTerminalLaunchResult('refused-shell')?.launchAgent).toBeUndefined()
expect(effectiveCommands).toEqual([undefined])
nativeOpen = false
await expect(resume({ mutationId: 'after-native-close' })).resolves.toMatchObject({
ok: true,
ptyId: 'resumed-C'
})
expect(effectiveCommands[1]).toContain(SESSION.id)
expect(spawn).toHaveBeenCalledTimes(2)
expect(runtime.getTerminalLaunchResult('refused-shell')).toBeDefined()
}
)
it('settles only fresh matching saved results and binds unproven or observed results until exit', async () => {
for (const kind of [
'matching',
'unverifiable',
'observed',
'other-session',
'other-agent',
'missing',
'contradictory',
'unknown'
]) {
const fixture = makeSleptWorkspace()
const record = storedRecord(fixture)!
record.origin = 'live'
const matching: TerminalLaunchResult = savedResumeResult({
verdict: kind === 'unverifiable' ? 'unverifiable' : 'chat-home',
refused: { agent: 'claude', providerSession: SESSION }
})
if (!matching.savedResumeDecision) {
throw new Error('Saved admission must return its decision')
}
const receipt =
kind === 'unknown'
? {}
: {
...matching,
savedResumeDecision:
kind === 'missing'
? undefined
: {
...matching.savedResumeDecision,
agent: kind === 'other-agent' ? 'codex' : 'claude',
providerSession: kind === 'other-session' ? { ...SESSION, id: 'D' } : SESSION,
verdict:
kind === 'contradictory'
? ('admitted' as const)
: matching.savedResumeDecision.verdict
},
...(kind === 'observed' ? { isReattach: true } : {})
}
let first = true
const { runtime, spawn, openOldPane, resume } = makeRuntime(fixture, {
spawn: async (args) => {
if (!first) {
return { id: 'next-C' }
}
first = false
expect(args).toMatchObject({
launchAgent: 'claude',
resumeOrigin: 'saved',
resumeProviderSession: SESSION
})
return { id: 'result-pty', ...receipt }
}
})
await openOldPane()
const proved = kind === 'matching' || kind === 'unverifiable'
const action = { mutationId: `after-${kind}` }
if (!proved) {
await expect(resume(action)).resolves.toEqual({ ok: false, reason: 'held_elsewhere' })
expect(spawn).toHaveBeenCalledOnce()
runtime.onPtyExit('result-pty', 0)
}
await expect(resume(action)).resolves.toMatchObject({ ok: true, ptyId: 'next-C' })
expect(spawn).toHaveBeenCalledTimes(2)
}
})
it('retains ownership after an ambiguous create with a running resume', async () => {
const fixture = makeSleptWorkspace()
const handle = deriveRemoteRuntimeTerminalCreateHandle(
@@ -120,7 +120,11 @@ describe('terminal stream extraction characterization', () => {
pendingOutputOverflowed: true,
outputBatcher: { push: vi.fn(), flush: vi.fn() }
} as unknown as TerminalMultiplexStream
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the runtime reads made by initial snapshot publication.
const runtime = {
getPtyExecutionHostMetadata: () => ({}),
getTerminalLaunchResult: () => undefined,
resolveLiveLeafForHandle: () => ({ ptyId: stream.ptyId }),
readTerminal: vi.fn(async () => {
readCount += 1
if (readCount === 2) {
@@ -94,6 +94,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.resumeOrigin ? { resumeOrigin: params.resumeOrigin } : {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalKittyKeyboardProtocol === true
@@ -21,7 +21,8 @@ export type MultiplexPublishedInitialState = {
export async function publishMultiplexInitialSnapshot(
state: TerminalMultiplexConnection,
request: MultiplexSubscribeRequest,
stream: TerminalMultiplexStream
stream: TerminalMultiplexStream,
incarnationId = state.runtime.getPtyExecutionHostMetadata(stream.ptyId).incarnationId
): Promise<MultiplexPublishedInitialState | null> {
const { runtime, emit } = state
const { ptyId } = stream
@@ -55,7 +56,23 @@ export async function publishMultiplexInitialSnapshot(
const layoutSeq = runtime.getLayout(ptyId)?.seq
// Why: layout versions and output offsets are different sequence domains.
const snapshotOutputSeq = serialized?.seq
// Snapshot awaits may outlive the handle binding or the physical shell.
if (
runtime.resolveLiveLeafForHandle(request.terminal)?.ptyId !== ptyId ||
runtime.getPtyExecutionHostMetadata(ptyId).incarnationId !== incarnationId
) {
throw new Error('terminal_handle_stale')
}
let launchResult: ReturnType<typeof runtime.getTerminalLaunchResult>
try {
launchResult = runtime.getTerminalLaunchResult(ptyId)
} catch (error) {
console.warn('Terminal launch result unavailable', error)
}
emit({
...(incarnationId && launchResult
? { launchResult: { ...launchResult, ptyId, incarnationId } }
: {}),
type: 'subscribed',
streamId: request.streamId,
terminal: request.terminal,
@@ -21,6 +21,7 @@ export function installMultiplexSubscribeFrame(state: TerminalMultiplexConnectio
if (!ptyId) {
return
}
const incarnationId = state.runtime.getPtyExecutionHostMetadata(ptyId).incarnationId
let stream: TerminalMultiplexStream | null = null
let installedStream: TerminalMultiplexStream | null = null
try {
@@ -46,7 +47,7 @@ export function installMultiplexSubscribeFrame(state: TerminalMultiplexConnectio
return
}
try {
const published = await publishMultiplexInitialSnapshot(state, request, stream)
const published = await publishMultiplexInitialSnapshot(state, request, stream, incarnationId)
if (!published) {
return
}
@@ -16,8 +16,11 @@ import {
// Preserve pendingEscapeTailAnsi through SnapshotStart (#7329); after reset, missing partial escapes make subsequent live bytes render literally.
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke.
return {
getRuntimeId: () => 'test-runtime',
getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }),
getTerminalLaunchResult: () => undefined,
...overrides
} as OrcaRuntimeService
}
@@ -108,14 +108,18 @@ describe('terminal multiplex RPC', () => {
})
it("waits for a desktop multiplex subscriber's PTY before retiring the terminal", async () => {
let ptyId: string | null = null
let resolvePty: (ptyId: string) => void = () => {}
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
resolveLiveLeafForHandle: vi.fn(() => ({ ptyId })),
requestRendererTerminalTabMount: vi.fn(),
waitForLeafPtyId: vi.fn(
() =>
new Promise<string>((resolve) => {
resolvePty = resolve
resolvePty = (resolvedPtyId) => {
ptyId = resolvedPtyId
resolve(resolvedPtyId)
}
})
)
})
@@ -359,17 +363,23 @@ describe('terminal multiplex RPC', () => {
const activeStreamCount = 44
const waitSignals: AbortSignal[] = []
const resolveWaits: ((ptyId: string) => void)[] = []
const ptyIds = new Map<string, string>()
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn((terminal: string) =>
terminal.startsWith('pending-') ? { ptyId: null } : { ptyId: `pty-${terminal}` }
terminal.startsWith('pending-')
? { ptyId: ptyIds.get(terminal) ?? null }
: { ptyId: `pty-${terminal}` }
),
waitForLeafPtyId: vi.fn(
(_handle: string, _timeoutMs?: number, signal?: AbortSignal) =>
(handle: string, _timeoutMs?: number, signal?: AbortSignal) =>
new Promise<string>((resolve, reject) => {
if (signal) {
waitSignals.push(signal)
}
resolveWaits.push(resolve)
resolveWaits.push((ptyId) => {
ptyIds.set(handle, ptyId)
resolve(ptyId)
})
signal?.addEventListener('abort', () => reject(new Error('request_aborted')), {
once: true
})
@@ -15,15 +15,22 @@ import { startDesktopMultiplexSubscribe } from './terminal-multiplex-test-harnes
function startPendingPtyHarness() {
const waits = new Map<string, { signal: AbortSignal; resolve: (ptyId: string) => void }>()
const ptyIds = new Map<string, string>()
const requestRendererTerminalTabMount = vi.fn()
const harness = startDesktopMultiplexSubscribe({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
resolveLiveLeafForHandle: vi.fn((handle: string) => ({ ptyId: ptyIds.get(handle) ?? null })),
requestRendererTerminalTabMount,
waitForLeafPtyId: vi.fn(
(handle: string, _timeoutMs?: number, signal?: AbortSignal) =>
new Promise<string>((resolve, reject) => {
if (signal) {
waits.set(handle, { signal, resolve })
waits.set(handle, {
signal,
resolve: (ptyId) => {
ptyIds.set(handle, ptyId)
resolve(ptyId)
}
})
}
signal?.addEventListener('abort', () => reject(new Error('request_aborted')), {
once: true
@@ -49,8 +49,11 @@ async function setupMultiplexStream(): Promise<{
return { data: snapshot.data, cols: 80, rows: 24, seq: snapshot.seq }
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke.
const runtime = {
getRuntimeId: () => 'test-runtime',
getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }),
getTerminalLaunchResult: () => undefined,
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: serializeSnapshot,
@@ -25,6 +25,8 @@ export function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRu
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke.
return {
getRuntimeId: () => 'test-runtime',
getPtyExecutionHostMetadata: () => ({}),
getTerminalLaunchResult: () => undefined,
subscribeToPtyExit: vi.fn(() => vi.fn()),
// View registration suppresses competing query responders (terminal-query-authority.md).
registerRemoteTerminalViewSubscriber: () => () => {},
@@ -50,8 +50,11 @@ async function requestSnapshotReply(options: {
options.duringSerialize?.(requestedAttempt, (data) => dataListenerRef.current?.(data))
return options.serializeRequested(requestedAttempt)
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke.
const runtime = {
getRuntimeId: () => 'test-runtime',
getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }),
getTerminalLaunchResult: () => undefined,
registerRemoteTerminalViewSubscriber: () => () => {},
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
requestRendererTerminalTabMount: vi.fn().mockReturnValue(true),
@@ -6,7 +6,6 @@ import type {
SpawnIntent
} from '../../shared/host-spawn-admission'
import type {
AgentSessionClaimedSpawnResult,
AgentSessionExecutionClaim,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
@@ -68,11 +67,14 @@ export type RuntimePtyController = {
cwd?: string
command?: string
launchAgent?: TuiAgent
launchConfig?: WorktreeStartupLaunch['launchConfig']
launchToken?: string
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
env?: Record<string, string>
envToDelete?: string[]
resumeProviderSession?: AgentProviderSessionMetadata
resumeOrigin?: 'saved'
telemetry?: WorktreeStartupLaunch['telemetry']
connectionId?: string | null
worktreeId?: string
@@ -115,17 +117,13 @@ export type RuntimePtyController = {
}
materialized?: true
}
}): Promise<{
id: string
pid?: number | null
incarnationId?: PtyIncarnationId
wslDistro?: string
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
agentSessionEnsure?: AgentSessionClaimedSpawnResult
/** The host started this process at the workspace root because the requested folder is gone. */
startupCwdFallback?: StartupCwdFallback
savedStartupCwd?: string
}>
}): Promise<
PtySpawnResult & {
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
startupCwdFallback?: StartupCwdFallback
savedStartupCwd?: string
}
>
/** The local terminal owner's automatic-restore state; null when it has no fork gate. */
getAutomaticWorkStatus?(): Promise<AutomaticWorkStatus | null>
resumeAutomaticWork?(): Promise<AutomaticWorkResumeOutcome>
@@ -40,6 +40,8 @@ export type TerminalCreateOptions = {
envToDelete?: string[]
launchConfig?: WorktreeStartupLaunch['launchConfig']
resumeProviderSession?: AgentProviderSessionMetadata
/** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */
resumeOrigin?: 'saved'
launchToken?: string
launchAgent?: TuiAgent
startupAgent?: TuiAgent
@@ -1,6 +1,9 @@
import type { AgentStatus } from '../../shared/agent-detection'
import type { AgentStatusState } from '../../shared/agent-status-types'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type {
TerminalLaunchResult,
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { RuntimeSyncedLeaf } from '../../shared/runtime-types'
import type { RuntimeTerminalRowMetadata } from '../../shared/runtime-terminal-contracts'
@@ -67,6 +70,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
paneKey?: string | null
/** Window-owned L80 graphSequence when paneKey was recorded; newer surfaces have no graph statement that could contradict them (pty-recorded-surface-topology.ts). */
surfaceRecordedAtGraphSequence?: number
savedTerminalLaunchResult?: {
incarnationId: PtyIncarnationId | null
launchConfig: SleepingAgentLaunchConfig | null
launchToken: string | null
result: TerminalLaunchResult
}
/** The ORCA_PANE_KEY the execution host reports this PTY's env exported; absent when unreported. */
spawnPaneKey?: string | null
launchConfig: SleepingAgentLaunchConfig | null
@@ -160,6 +160,7 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O
launchAgent: request.agent,
// Host-built resumes must name their session so readers can address it.
resumeProviderSession: identity.providerSession,
...(request.resumeOrigin ? { resumeOrigin: request.resumeOrigin } : {}),
terminalImageViewerSupported: _caller.terminalImageViewerSupported,
launchRuntime,
terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol,
@@ -41,6 +41,10 @@ describe('AgentSessionResumeClaims', () => {
it('keeps a lost-reply attempt held unless a retry of the same request binds', () => {
const { claims, exits } = makeClaims()
claims.claim(SESSION, 'tab:a', 'request-1')?.settleFailure(true, new Error('reply lost'))
// Proof that this share dispatched no agent cannot account for the earlier lost reply.
claims.claim(SESSION, 'tab:a', 'request-1')?.settleFailure(false)
expect(exits.has('plain-shell')).toBe(false)
expect(claims.claim(SESSION, 'tab:b')).toBeNull()
// Another request's spawn accounts only for itself.
claims.claim(SESSION, 'tab:a', 'request-2')?.bind('pty-2')
exits.get('pty-2')?.()
@@ -26,7 +26,7 @@ type Hold = AgentSessionResumeHold & {
export type AgentSessionResumeClaim = {
/** The exact PTY this resumer's create produced; the hold ends once every such PTY exits. */
bind: (ptyId: string) => void
/** Releases when nothing was dispatched or the spawn proved its exit; otherwise stays held. */
/** Releases on proof this share dispatched no claimed agent, or its spawn exited; otherwise held. */
settleFailure: (dispatched: boolean, error?: unknown) => void
}
@@ -15,6 +15,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-session-contracts'
import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types'
import type { RuntimeTerminalCreate } from '../../../../shared/runtime-terminal-create-contract'
import { buildHeadlessMobileSessionTerminalTabs } from '../../mobile-session-terminal-projection'
import {
RestoredPaneStarter,
@@ -27,6 +28,7 @@ import { reopenNewerDaemonTerminals } from '../../../daemon/daemon-newer-protoco
import type { NewerDaemonReopenResult } from '../../../../shared/newer-daemon-hold'
import type { WorkspaceLayoutStream } from '../../workspace-layout-stream'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
import { worktreeIdsEqual } from '../../../../shared/worktree/id'
export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs {
@@ -215,6 +217,7 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu
...('resumeProviderSession' in agentStartup
? {
resumeProviderSession: agentStartup.resumeProviderSession,
resumeOrigin: 'saved' as const,
spawnIntent: automatic ? 'automatic-restore' : 'user-resume'
}
: {}),
@@ -223,7 +226,30 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu
onPtySpawnDispatched: () => {
dispatched = true
},
...(resumeClaim ? { onPtyCreated: resumeClaim.bind } : {}),
...(resumeClaim
? {
onPtyCreated: (ptyId: string, result: RuntimeTerminalCreate) => {
const decision = result.savedResumeDecision
if (
!result.isReattach &&
result.agentSessionDisposition !== 'adopted' &&
decision &&
decision.agent === resumeStartup?.launchAgent &&
decision.verdict !== 'admitted' &&
decision.verdict === result.agentResumeRefused &&
agentProviderSessionsEqual(
decision.agent,
decision.providerSession,
resumeStartup?.resumeProviderSession
)
) {
resumeClaim.settleFailure(false)
} else {
resumeClaim.bind(ptyId)
}
}
}
: {}),
...(automatic ? { refuseSleptWorktree: true } : {}),
// Only resumes from this pane's record show the window banner.
...(resumedRecord ? { resumesSleepingRecord: true as const } : {})
@@ -1,3 +1,8 @@
import { hostSleepKey } from '../terminal/stop/worktree-terminal-sleep-key'
import {
terminalLaunchFacts,
type TerminalLaunchResult
} from '../../../shared/agent-session-resume'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing'
@@ -16,8 +21,41 @@ import {
OPEN_CODE_WRITER_TOPOLOGY,
cliWriterTopology
} from '../../agent-hooks/server/cli-writer-observation'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption'
import { recordConversationKey } from '../../native-chat/structured-conversation-key'
export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs {
rememberSavedTerminalLaunchResult(ptyId: string, result: TerminalLaunchResult): void {
const pty = this.ptysById.get(ptyId)
if (pty && result.savedResumeDecision) {
pty.savedTerminalLaunchResult = {
incarnationId: pty.incarnationId,
launchConfig: pty.launchConfig,
launchToken: pty.launchToken,
result: terminalLaunchFacts(result)
}
}
}
getTerminalLaunchResult(ptyId: string): TerminalLaunchResult | undefined {
const pty = this.ptysById.get(ptyId)
const saved = pty?.savedTerminalLaunchResult
if (!pty) {
return undefined
}
return terminalLaunchFacts({
...(saved?.incarnationId === pty.incarnationId &&
saved.launchConfig === pty.launchConfig &&
saved.launchToken === pty.launchToken
? saved.result
: {}),
launchAgent: pty.launchAgent ?? undefined,
launchToken: pty.launchToken ?? undefined,
launchConfig: pty.launchConfig ?? undefined
})
}
private cliWriterPtyPane(ptyId: string | null | undefined): CliWriterPane | null {
const pty = ptyId ? this.ptysById.get(ptyId) : undefined
return pty?.connected && !pty.connectionId && !pty.isWsl && pty.incarnationId
@@ -54,6 +92,9 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca
if (!agent || !binding || !pty) {
return
}
if (event.agentPresence?.process) {
delete pty.savedTerminalLaunchResult
}
if (pty.cliConversations?.incarnationId !== pty.incarnationId) {
pty.cliConversations = { incarnationId: pty.incarnationId, byKey: new Map() }
}
@@ -70,6 +111,20 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca
})
}
/** Before a chat's agent spawns: refuses while a terminal may still write its head conversation.
* Never enters the session queue, so it can run inside an acquisition. */
protected async assertStructuredChatAcquireAdmissible(record: AgentSessionRecord): Promise<void> {
const head = record.providerHandleChain.at(-1)
const key = head ? recordConversationKey(record, head) : null
const adopts = Object.values(CLI_CONVERSATION_ADOPTING_AGENTS).some(
(agents: readonly string[]) => agents.includes(record.provider)
)
if (!key?.id || !adopts) {
return
}
await this.assertStructuredAdoptionTerminalWritersExited(record.provider, key.id)
}
async assertStructuredAdoptionTerminalWritersExited(
agent: StructuredAgentId,
providerSessionId: string,
@@ -105,12 +160,8 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca
}))
}),
sleeping: (workspaceId) =>
[...this.terminalSleepStateByHostWorktree.values()].some(
(state) =>
state.worktreeId === workspaceId &&
(state.phase === 'sleeping' ||
state.phase === 'partial' ||
state.phase === 'stopping')
this.terminalSleepStateByHostWorktree.has(
hostSleepKey(LOCAL_EXECUTION_HOST_ID, workspaceId)
),
shellAlone: async (pane) => {
if (process.platform === 'win32') {
@@ -1,3 +1,4 @@
import { hostSleepKey } from './terminal/stop/worktree-terminal-sleep-key'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import type { AgentProcessVerdict } from '../../shared/agent-process-presence'
@@ -131,6 +132,196 @@ describe('execution-host conversation writer inspection', () => {
}
})
it.each(['claude', 'codex', 'pi', 'opencode', 'opencode2'] as const)(
'classifies the saved %s note separately from a physical terminal',
async (agent) => {
const check = inspection()
const record: SleepingAgentSessionRecord = {
paneKey: 'source',
worktreeId: 'workspace',
agent,
providerSession: { key: 'session_id', id: 'conversation' },
prompt: '',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
}
check.host.saved = () => [record]
check.host.rows = () => []
check.host.sleeping = () => true
check.host.terminal = () => null
const run = () =>
assertCliConversationWritersExited(
check.host,
agent === 'opencode2' ? 'opencode' : agent,
'conversation',
{ paneKey: record.paneKey, worktreeId: record.worktreeId }
)
await (agent === 'opencode' || agent === 'opencode2'
? expect(run()).rejects.toMatchObject(refusal)
: expect(run()).resolves.toBeUndefined())
check.host.terminal = () => ({ pane: null })
await expect(run()).rejects.toMatchObject(refusal)
expect(check.host.shellAlone).not.toHaveBeenCalled()
}
)
it('uses normal physical proof for conclusively pane-owned OpenCode, retaining its saved-note guard', async () => {
const check = inspection()
check.host.rows = () => [{ ...row, agentType: 'opencode' }]
const saved: SleepingAgentSessionRecord = {
paneKey: row.paneKey,
worktreeId: 'workspace',
agent: 'opencode',
providerSession: { key: 'session_id', id: 'conversation' },
prompt: '',
state: 'done',
origin: 'live',
capturedAt: 1,
updatedAt: 1
}
check.host.saved = () => [saved]
const run = () => assertCliConversationWritersExited(check.host, 'opencode', 'conversation')
await expect(run()).resolves.toBeUndefined()
check.observation.presence = async () => 'live'
await expect(run()).rejects.toMatchObject(refusal)
})
it('probes a current row before admitting physical absence, even for a sleeping identity', async () => {
for (const verdict of [null, 'exited', 'live', 'unverifiable'] as const) {
const check = inspection(verdict)
check.host.terminal = () => null
check.host.sleeping = () => true
check.observation.topology = 'unverifiable'
const result = expect(check.run())
await (verdict === null || verdict === 'exited'
? result.resolves.toBeUndefined()
: result.rejects.toMatchObject(refusal))
expect(check.observation.presence).toHaveBeenCalledOnce()
}
const replaced = inspection()
replaced.host.terminal = () => null
replaced.observation.presence = async () => {
replaced.replaceRow()
return null
}
await expect(replaced.run()).rejects.toMatchObject(refusal)
})
it('rechecks an absent saved pane and captured identity after another candidate awaits', async () => {
for (const changed of ['terminal', 'capture'] as const) {
const check = inspection()
const record: SleepingAgentSessionRecord = {
paneKey: 'absent',
worktreeId: 'workspace',
agent: 'codex',
providerSession: { key: 'session_id', id: 'conversation' },
prompt: '',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
}
const other = { ...record, paneKey: 'other' }
check.host.saved = () => [record, other]
check.host.rows = () => []
const terminal = check.host.terminal
let appeared = false
check.host.terminal = (coordinates) =>
coordinates.paneKey === 'absent' && !appeared ? null : terminal(coordinates)
check.host.shellAlone = async () => {
if (changed === 'terminal') {
appeared = true
} else {
record.updatedAt += 1
}
return true
}
await expect(check.run()).rejects.toMatchObject(refusal)
}
})
it('retains a matching absent row observation across another saved candidate await', async () => {
const check = inspection()
const record: SleepingAgentSessionRecord = {
paneKey: row.paneKey,
worktreeId: 'workspace',
agent: 'codex',
providerSession: { key: 'session_id', id: 'conversation' },
prompt: '',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
}
const other = { ...record, paneKey: 'other' }
check.host.saved = () => [record, other]
const terminal = check.host.terminal
check.host.terminal = (coordinates) =>
coordinates.paneKey === row.paneKey ? null : terminal(coordinates)
check.host.shellAlone = async () => {
check.replaceRow()
return true
}
await expect(check.run()).rejects.toMatchObject(refusal)
})
it.each(['absent', 'shell'] as const)(
'retains %s current-row proof without a saved match across a later saved census',
async (physical) => {
for (const changed of ['unchanged', 'row', 'terminal'] as const) {
const check = inspection()
const other: SleepingAgentSessionRecord = {
paneKey: 'other',
worktreeId: 'workspace',
agent: 'codex',
providerSession: { key: 'session_id', id: 'conversation' },
prompt: '',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
}
check.host.saved = () => [other]
const terminal = check.host.terminal
const otherTerminal = terminal(other)
let appeared = false
check.host.terminal = (coordinates) =>
coordinates.paneKey !== row.paneKey
? otherTerminal
: physical === 'absent' && !appeared
? null
: terminal(coordinates)
const entered = Promise.withResolvers<void>()
const released = Promise.withResolvers<boolean>()
let scans = 0
check.host.shellAlone = async () => {
scans += 1
if (physical === 'shell' && scans === 1) {
return true
}
entered.resolve()
return released.promise
}
const result = check.run()
const assertion =
changed === 'unchanged'
? expect(result).resolves.toBeUndefined()
: expect(result).rejects.toMatchObject(refusal)
await entered.promise
if (changed === 'row') {
check.replaceRow()
} else if (changed === 'terminal') {
appeared = true
check.replacePty()
}
released.resolve(true)
await assertion
}
}
)
it('requires current owned shell proof for saved resumes, including done checkpoints', async () => {
const record: SleepingAgentSessionRecord = {
paneKey: 'source',
@@ -217,7 +408,10 @@ describe('execution-host conversation writer inspection', () => {
const pane = `tab-a:${leaf}`
let rows: AgentStatusIpcPayload[] = []
const store = ORCA_LAYOUT_OWNER_IS_MAIN ? writerLayoutStore() : null
const runtime = new OrcaRuntimeService(store, undefined, {
class WriterRuntime extends OrcaRuntimeService {
readonly sleepStates = this.terminalSleepStateByHostWorktree
}
const runtime = new WriterRuntime(store, undefined, {
getAgentProviderSessionSnapshot: () => rows,
readHookCliWriterObservation: () => ({
current: () => true,
@@ -268,6 +462,24 @@ describe('execution-host conversation writer inspection', () => {
}
// The header switch inspects the pane it leaves even when nothing recorded the conversation.
await census(() => check('claude', { workspaceId: 'workspace', paneKey: pane }))
// Another host's same-id workspace cannot hold this local source; each local sleep phase does.
const source = { workspaceId: 'workspace', paneKey: pane }
for (const phase of ['sleeping', 'partial', 'stopping'] as const) {
const state = {
worktreeId: 'workspace',
generation: 1,
phase,
ptyIds: [],
terminalHandles: [],
terminalHandlesByPtyId: {}
}
runtime.sleepStates.set(hostSleepKey('ssh:twin', 'workspace'), state)
await expect(check('claude', source)).resolves.toBeUndefined()
runtime.sleepStates.set(hostSleepKey('local', 'workspace'), state)
await expect(check('claude', source)).rejects.toMatchObject(refusal)
runtime.sleepStates.delete(hostSleepKey('local', 'workspace'))
}
runtime.sleepStates.clear()
// A row restored after restart has no observed identity; the pane's current PTY decides.
rows = [{ ...row, paneKey: pane, agentType: 'claude' }]
await census(() => check('claude'))
@@ -74,6 +74,7 @@ export async function assertCliConversationWritersExited(
chatAgentsShareConversations(rowChat, agent)
) &&
id === providerSessionId
const proofs: (() => boolean)[] = []
const paneOf = (coordinates: CliWriterCoordinates) => host.terminal(coordinates)?.pane ?? null
// The pane's PTY itself, not a recorded pid: a wrapper pid can read exited beside a paused agent.
const censusPane = async (
@@ -83,6 +84,7 @@ export async function assertCliConversationWritersExited(
) => {
const terminal = read()
if (!terminal && absentAdmits) {
proofs.push(() => !read())
return
}
const pane = terminal?.pane
@@ -94,10 +96,11 @@ export async function assertCliConversationWritersExited(
) {
throw refuse()
}
proofs.push(() => !host.sleeping(worktreeId ?? '') && read()?.pane?.identity === pane.identity)
}
try {
if (source) {
await censusPane(() => host.terminal(source), source.worktreeId, false)
await censusPane(() => host.terminal(source), source.worktreeId, true)
}
const savedCandidates = () =>
host
@@ -143,25 +146,35 @@ export async function assertCliConversationWritersExited(
record.providerSession.id === row.providerSession?.id
const matching = saved.filter(matches)
const observed = host.observation(row)
if (
!observed ||
!observed.current() ||
observed.topology !== 'pane-owned' ||
host.sleeping(row.worktreeId ?? '')
) {
if (!observed || !observed.current()) {
throw refuse()
}
proofs.push(() => observed.current())
const pane = paneOf(row)
const presence = await observed.presence()
if (
!observed.current() ||
presence === 'live' ||
presence === 'unverifiable' ||
(pane && paneOf(row)?.identity !== pane.identity) ||
host.sleeping(row.worktreeId ?? '')
(pane && paneOf(row)?.identity !== pane.identity)
) {
throw refuse()
}
if (!host.terminal(row)) {
proofs.push(() => !host.terminal(row))
if (row.agentType !== 'opencode' && row.agentType !== 'opencode2') {
for (const record of matching) {
covered.set(
record,
() => savedCurrent(record) && observed.current() && !host.terminal(row)
)
}
}
continue
}
if (observed.topology !== 'pane-owned' || host.sleeping(row.worktreeId ?? '')) {
throw refuse()
}
if (presence === 'exited' && matching.length === 0) {
await censusPane(() => host.terminal(row), row.worktreeId, true)
if (!observed.current()) {
@@ -179,16 +192,11 @@ export async function assertCliConversationWritersExited(
) {
throw refuse()
}
const current = () =>
paneOf(row)?.identity === pane.identity && !host.sleeping(row.worktreeId ?? '')
proofs.push(current)
for (const record of matching) {
covered.set(
record,
() =>
observed.current() &&
paneOf(row)?.identity === pane.identity &&
!host.sleeping(row.worktreeId ?? '') &&
matches(record) &&
host.saved().includes(record)
)
covered.set(record, () => current() && savedCurrent(record) && matches(record))
}
}
for (const link of host.links()) {
@@ -213,7 +221,12 @@ export async function assertCliConversationWritersExited(
throw refuse()
}
const coordinates = { paneKey, worktreeId }
const pane = paneOf(coordinates)
const terminal = host.terminal(coordinates)
if (!terminal) {
covered.set(record, () => savedCurrent(record) && !host.terminal(coordinates))
continue
}
const pane = terminal.pane
if (!pane) {
throw refuse()
}
@@ -228,6 +241,7 @@ export async function assertCliConversationWritersExited(
covered.set(record, current)
}
if (
proofs.some((current) => !current()) ||
[...saved, ...savedCandidates()].some(
(record) => !savedCurrent(record) || !covered.get(record)?.()
)
@@ -3,7 +3,6 @@
import type { AgentSessionAccountKind } from '../../shared/agent-session-availability'
import { bindAtRestCommandContexts } from '../native-chat/agent-session-wire/at-rest-command-context'
import type { StructuredAgentSessionTransportOverrides } from './structured-agent-session-transport-overrides'
import { existsSync } from 'node:fs'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker'
import type { StructuredAttentionMobileDelivery } from './structured-agent-session-mobile-attention'
@@ -30,8 +29,6 @@ import {
releaseAgentSessionRecordStore,
type OpenedAgentSessionRecordStore
} from './agent-session-record-store-slot'
import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database'
import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database'
import {
createStructuredAgentSessionOwnerProbe,
createStructuredAgentSessionOwnerProbes
@@ -55,22 +52,7 @@ import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support'
import { migrateLegacyNativeChatVisualsFolders } from '../native-chat/native-chat-scratch-folder-migration'
import { scheduleNativeChatVisualsSweep } from '../native-chat/native-chat-visuals-sweep'
/** Whether this profile holds a structured chat: a record or tab in the journal database. */
export function hasPersistedStructuredAgentSessionStore(
stateDirectory: string,
fileExists: (path: string) => boolean = existsSync
): boolean {
const databasePath = journalDatabasePath(stateDirectory)
if (!fileExists(databasePath)) {
return false
}
try {
return journalDatabaseHoldsAgentSessions(databasePath)
} catch {
// A database that cannot be read cannot say it is empty.
return true
}
}
export { hasPersistedStructuredAgentSessionStore } from './agent-session-record-store-slot'
export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & {
stateDirectory: string
@@ -109,6 +91,8 @@ export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportO
prepareCodexCatalogProbeHome?: (homePath: string) => void
/** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */
onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden']
/** See `StructuredAgentSessionHostDeps.assertNoTerminalWriter`. */
assertNoTerminalWriter?: StructuredAgentSessionHostDeps['assertNoTerminalWriter']
/** Host-owned phone delivery and reconciliation from the current journal projection. */
attentionDelivery?: StructuredAttentionMobileDelivery
/** Resolve the account home launch would pin for catalog reads without a session; absent disables catalogs. */
@@ -278,6 +262,7 @@ async function installOnJournal(
...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}),
...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}),
...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}),
...(deps.assertNoTerminalWriter ? { assertNoTerminalWriter: deps.assertNoTerminalWriter } : {}),
...(await modelCatalogHostDeps({
store,
agents,
@@ -1,6 +1,8 @@
import { OrcaRuntimeWithCreateMobileSessionTerminal } from './mobile-terminal-create'
import {
OrcaRuntimeWithCreateMobileSessionTerminal,
type OwnedMobileTerminalCreateOptions
} from './mobile-terminal-create'
import type { MobileTerminalCreateOptions } from './mobile-terminal-renderer-create'
import type { OwnedMobileTerminalCreateOptions } from './mobile-terminal-owned-create'
import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types'
import {
@@ -8,11 +8,46 @@ import type {
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { RuntimeNavigationTarget } from '../../../../shared/runtime-navigation'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types'
import type {
RuntimeTerminalCreate,
RuntimeMobileSessionCreateTerminalResult,
RuntimeTerminalPresentation
} from '../../../../shared/runtime-types'
import type { SpawnIntent } from '../../../../shared/host-spawn-admission'
import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime'
import { navigationTargetsHost } from '../../../../shared/runtime-navigation'
import { MOBILE_TERMINAL_CREATE_RESULT_TTL_MS } from '../../orca-runtime-core'
import { seedResumeLaunchIdentity } from '../../../ipc/pty/pane/resume-launch-identity'
export type OwnedMobileTerminalCreateOptions = {
command?: string
shellOverride?: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
identity?: { tabId: string; leafId: string; sessionId?: string }
createMutation?: { clientIdentity: string; id: string }
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
afterDesktopTabId?: string
presentation?: RuntimeTerminalPresentation
supportsSplitGroupPlacement?: boolean
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
resumeOrigin?: 'saved'
spawnIntent?: SpawnIntent
launchRuntime?: AgentLaunchRuntime
signal?: AbortSignal
refuseSleptWorktree?: boolean
resumesSleepingRecord?: true
/** See TerminalCreateOptions; a create that throws before it proves nothing spawned. */
onPtySpawnDispatched?: () => void
/** The PTY this create produced, reported before publication can fail. */
onPtyCreated?: (ptyId: string, result: RuntimeTerminalCreate) => void
}
export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRuntimeWithFailedStartPanes {
protected abstract runCreateMobileSessionTerminal(
worktreeSelector: string,
@@ -1,18 +1,12 @@
import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './mobile-terminal-startup'
import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { SpawnIntent } from '../../../../shared/host-spawn-admission'
import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import type {
RuntimeMobileSessionCreateTerminalResult,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab,
RuntimeTerminalPresentation
RuntimeMobileSessionTerminalTab
} from '../../../../shared/runtime-types'
import type { OwnedMobileTerminalCreateOptions } from './mobile-terminal-create'
import { randomUUID } from 'node:crypto'
import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution'
import { deriveRemoteRuntimeTerminalCreateHandle } from '../../remote-runtime-terminal-create-identity'
@@ -27,34 +21,6 @@ import {
const OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS = 15 * 60_000
export type OwnedMobileTerminalCreateOptions = {
command?: string
shellOverride?: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
identity?: { tabId: string; leafId: string; sessionId?: string }
createMutation?: { clientIdentity: string; id: string }
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
afterDesktopTabId?: string
presentation?: RuntimeTerminalPresentation
supportsSplitGroupPlacement?: boolean
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
spawnIntent?: SpawnIntent
launchRuntime?: AgentLaunchRuntime
signal?: AbortSignal
refuseSleptWorktree?: boolean
resumesSleepingRecord?: true
/** See TerminalCreateOptions; a create that throws before it proves nothing spawned. */
onPtySpawnDispatched?: () => void
/** The PTY this create produced, reported before publication can fail. */
onPtyCreated?: (ptyId: string) => void
}
export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand {
private readonly ownedMobileDispatchRecipes = new Map<
string,
@@ -132,6 +98,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
? { resumeProviderSession: opts.resumeProviderSession }
: {}),
...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}),
...(opts.resumeOrigin ? { resumeOrigin: opts.resumeOrigin } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
@@ -164,7 +131,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
)
: await create()
if (terminal.ptyId) {
opts.onPtyCreated?.(terminal.ptyId)
opts.onPtyCreated?.(terminal.ptyId, terminal)
}
const livePty = this.getLivePtyForHandle(terminal.handle)
if (!livePty) {
@@ -236,8 +203,8 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
...((terminal.savedStartupCwd ?? cwd) && (terminal.savedStartupCwd ?? cwd) !== workspace.path
? { startupCwd: terminal.savedStartupCwd ?? cwd }
: {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
...(livePty.pty.launchAgent ? { launchAgent: livePty.pty.launchAgent } : {}),
...(!terminal.agentResumeRefused && opts.viewMode ? { viewMode: opts.viewMode } : {}),
parentLayout,
isActive: activate
}
@@ -1,3 +1,8 @@
import {
recordTerminalLaunchMetadata,
resolveTerminalLaunchResult,
terminalLaunchPublicFacts
} from './terminal-launch-result'
import { revealCreatedTerminal } from './terminal-create-reveal'
import type { PaneSpawnOperation } from '../../../ipc/pty/pane/pane-spawn-operation'
import { OrcaRuntimeWithTerminalCreateDeduplication } from './terminal-create-deduplication'
@@ -124,6 +129,8 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
launchConfig: effectiveLaunchConfig,
...(launchToken ? { launchToken } : {}),
commandDelivery: 'provider',
startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
@@ -132,6 +139,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
agentTeamsPlan?.envToDelete
),
resumeProviderSession: launchOpts.resumeProviderSession,
...(launchOpts.resumeOrigin ? { resumeOrigin: launchOpts.resumeOrigin } : {}),
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
@@ -195,6 +203,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
})
if (adoptedBeforeLaunch) {
result = dependencies.materializedStablePaneReply(adoptedBeforeLaunch, preAllocatedHandle)
Object.assign(result, this.getTerminalLaunchResult(result.id))
} else if (!ptyController.adoptStablePane) {
result = await spawnFresh({ mustExist: false })
}
@@ -204,28 +213,18 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
restoreHint.consume()
const savedStartupCwd = result.savedStartupCwd ?? cwd
// Why here: refused before a handle, snapshot or reveal could pose the live PTY as new.
const adoptedStablePane = dependencies.admitStablePaneAdoption(result, launchOpts)
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = dependencies.makePaneKey(tabId, leafId)
} else if (result.stablePaneOwner) {
preAllocatedHandle = result.stablePaneOwner.handle
tabId = result.stablePaneOwner.tabId
leafId = result.stablePaneOwner.leafId
const launch = resolveTerminalLaunchResult(result, {
...launchOpts,
launchConfig: effectiveLaunchConfig,
launchToken
})
if (launch.surface) {
;({ terminalHandle: preAllocatedHandle, tabId, leafId } = launch.surface)
paneKey = dependencies.makePaneKey(tabId, leafId)
}
try {
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
}
throw error
}
if (!adoptedStablePane) {
this.assertCreatedPtyRegistrationAllowed(result.id, result.incarnationId)
// Provider reattachment still needs this runtime's handle and workspace binding.
if (!result.stablePaneOwner) {
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
@@ -240,29 +239,29 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
pty.runtimeSessionOwned = true
if (!adoptedStablePane) {
seedPtyLaunchTitle(pty, launchOpts.title, launchTitleAt, (title, at) =>
if (!launch.adoptedStablePane) {
seedPtyLaunchTitle(pty, launch.effectiveTitle, launchTitleAt, (title, at) =>
this.setPtyManagementTitleFromObservedTitle(pty, title, at)
)
pty.launchConfig = effectiveLaunchConfig
? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchIncarnationId = launchToken ? pty.incarnationId : null
pty.launchAgent = launchOpts.launchAgent ?? null
pty.launchCommandAgent = resolveLaunchCommandPaneAgent(launchOpts.command)
}
recordTerminalLaunchMetadata(pty, result, launch)
if (!launch.adoptedStablePane) {
pty.launchCommandAgent = result.agentResumeRefused
? null
: resolveLaunchCommandPaneAgent(launchOpts.command)
this.rememberSavedTerminalLaunchResult(result.id, result)
}
recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence))
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
if (pty && !launch.adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
title: launchOpts.title ?? null,
title: launch.effectiveTitle ?? null,
activate: presentation === 'focused',
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(launch.effectiveViewMode ? { viewMode: launch.effectiveViewMode } : {}),
...(savedStartupCwd !== workspace.path ? { startupCwd: savedStartupCwd } : {})
})
}
@@ -272,29 +271,31 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
ptyId: result.id,
handle,
cwd: savedStartupCwd,
launchOpts,
effectiveLaunchConfig,
launchToken,
launchOpts: {
...launchOpts,
title: pty?.title ?? launch.effectiveTitle ?? undefined,
launchAgent: pty?.launchAgent ?? undefined,
viewMode: launch.effectiveViewMode
},
effectiveLaunchConfig: pty?.launchConfig ?? undefined,
launchToken: pty?.launchToken ?? undefined,
presentation,
opts,
tabId,
leafId
})
const { surface, warning } = reveal instanceof Promise ? await reveal : reveal
const agentSessionDisposition = result.agentSessionEnsure?.disposition
return {
...terminalLaunchPublicFacts(result, launch.adoptedStablePane, pty),
handle,
savedStartupCwd,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: pty?.title ?? launchOpts.title ?? null,
title: pty?.title ?? launch.effectiveTitle ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.pid ? { processId: result.pid } : {}),
...(result.agentSessionEnsure ? { agentSessionDisposition } : {}),
...(adoptedStablePane ? { isReattach: true as const } : {}),
...(result.startupCwdFallback ? { startupCwdFallback: result.startupCwdFallback } : {}),
...(warning ? { warning } : {})
}
@@ -0,0 +1,86 @@
import { terminalLaunchFacts } from '../../../../shared/agent-session-resume'
import { admitStablePaneAdoption } from '../../runtime-terminal-pane-identity'
import type { RuntimePtyController } from '../../runtime-pty-controller-contract'
import type { TerminalCreateOptions } from '../../runtime-terminal-contracts'
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution'
type TerminalSpawnResult = Awaited<ReturnType<NonNullable<RuntimePtyController['spawn']>>>
export function resolveTerminalLaunchResult(
result: TerminalSpawnResult,
requested: Pick<
TerminalCreateOptions,
'launchConfig' | 'launchAgent' | 'launchToken' | 'title' | 'viewMode' | 'requireFreshPane'
>
) {
const stablePaneAdopted = admitStablePaneAdoption(result, requested)
const adoptedStablePane =
stablePaneAdopted ||
result.isReattach === true ||
result.agentSessionEnsure?.disposition === 'adopted'
const refused = Boolean(result.agentResumeRefused)
const owner = result.stablePaneOwner
const surface =
result.agentSessionEnsure?.owner.surface ??
(owner ? { terminalHandle: owner.handle, tabId: owner.tabId, leafId: owner.leafId } : undefined)
return {
adoptedStablePane,
surface,
launchAgent: refused
? undefined
: (result.launchAgent ?? (adoptedStablePane ? undefined : requested.launchAgent)),
launchConfig: refused
? undefined
: (result.launchConfig ?? (adoptedStablePane ? undefined : requested.launchConfig)),
launchToken: refused
? undefined
: (result.launchToken ?? (adoptedStablePane ? undefined : requested.launchToken)),
effectiveTitle: refused || adoptedStablePane ? null : requested.title,
effectiveViewMode: refused || adoptedStablePane ? undefined : requested.viewMode
}
}
export function recordTerminalLaunchMetadata(
pty: RuntimePtyWorktreeRecord,
result: TerminalSpawnResult,
launch: ReturnType<typeof resolveTerminalLaunchResult>
): void {
if (launch.adoptedStablePane) {
// A lower owner can supply facts before this runtime has a launch record.
if (!pty.launchConfig && result.launchConfig) {
pty.launchConfig = copySleepingAgentLaunchConfig(result.launchConfig)
}
pty.launchToken ??= result.launchToken ?? null
pty.launchAgent ??= result.launchAgent ?? null
if (pty.launchToken && !pty.launchIncarnationId) {
pty.launchIncarnationId = pty.incarnationId
}
} else {
pty.launchConfig = launch.launchConfig
? copySleepingAgentLaunchConfig(launch.launchConfig)
: null
pty.launchToken = launch.launchToken ?? null
pty.launchIncarnationId = launch.launchToken ? pty.incarnationId : null
pty.launchAgent = launch.launchAgent ?? null
}
}
export function terminalLaunchPublicFacts(
result: TerminalSpawnResult,
adoptedStablePane: boolean,
pty: RuntimePtyWorktreeRecord | null
) {
return {
...terminalLaunchFacts(result),
launchConfig: pty?.launchConfig ?? undefined,
launchAgent: pty?.launchAgent ?? undefined,
launchToken: pty?.launchToken ?? undefined,
...(!adoptedStablePane && result.coldRestore ? { coldRestore: result.coldRestore } : {}),
...(result.pid ? { processId: result.pid } : {}),
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(adoptedStablePane ? { isReattach: true as const } : {})
}
}
@@ -195,6 +195,20 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida
this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId)
}
protected assertCreatedPtyRegistrationAllowed(
ptyId: string,
incarnationId?: PtyIncarnationId
): void {
try {
this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(ptyId, incarnationId)
}
throw error
}
}
releaseRejectedPtyRegistrationFence(
ptyId: string,
candidateIncarnation?: PtyIncarnationId
@@ -1,3 +1,4 @@
import { terminalLaunchFacts } from '../../../../shared/agent-session-resume'
import type { OrcaRuntimeWithCreateTerminal } from '../launch/terminal-create'
import { OrcaRuntimeWithRestoredHandlePaneStart } from '../surface/restored-handle-pane-start'
import type {
@@ -168,6 +169,7 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith
leafId: parsed.leafId,
focus: false
}).then((terminal) => ({
...terminalLaunchFacts(terminal),
handle: terminal.handle,
tabId: parsed.tabId,
leafId: parsed.leafId,
@@ -1,3 +1,4 @@
import { sleepingRecordsEquivalentIgnoringCaptureTime } from '../../../../shared/agent-resume-record-equivalence'
import { OrcaRuntimeWithStopTerminalsForWorktree } from './worktree-terminal-stop'
import {
applyWorktreeSleepResumeRecords,
@@ -16,7 +17,11 @@ import type {
AgentStatusClearIpcPayload,
AgentStatusIpcPayload
} from '../../../../shared/agent-status-ipc-payload'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import {
agentProviderSessionsEqual,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import type { RefusedPaneResume } from '../../../../shared/agent-status-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
@@ -24,6 +29,66 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switc
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRuntimeWithStopTerminalsForWorktree {
/** Capture after earlier status writes so their saved copy participates in exact cleanup. */
readSavedResumeSource(
worktreeId: string,
paneKey: string
): Promise<SleepingAgentSessionRecord | undefined> {
return this.inResumeRecordOrder(
async () =>
this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId))
?.sleepingAgentSessionsByPaneKey?.[paneKey]
)
}
/** Forgets a pane's saved resume the host refused because a chat is its conversation's home,
* as a window does for its own panes; a host with no window has no one else to. */
async forgetRefusedSavedResume(
worktreeId: string,
paneKey: string,
refused: RefusedPaneResume,
source?: SleepingAgentSessionRecord
): Promise<void> {
await this.inResumeRecordOrder(() =>
this.commitWorkspaceSessionEdit(
this.getWorkspaceSessionHostIdForWorktree(worktreeId),
(session) => {
const saved = session.sleepingAgentSessionsByPaneKey?.[paneKey]
if (
!source ||
!refused.expectedSavedRecord ||
saved?.paneKey !== refused.expectedSavedRecord.paneKey ||
saved.capturedAt !== refused.expectedSavedRecord.capturedAt ||
saved.updatedAt !== refused.expectedSavedRecord.updatedAt ||
saved.connectionId !== source.connectionId ||
!sleepingRecordsEquivalentIgnoringCaptureTime(saved, source) ||
saved.agent !== refused.agent ||
!agentProviderSessionsEqual(
refused.agent,
saved.providerSession,
refused.providerSession
)
) {
return null
}
const { [paneKey]: _refused, ...kept } = session.sleepingAgentSessionsByPaneKey ?? {}
return { ...session, sleepingAgentSessionsByPaneKey: kept }
}
)
)
const previous = this.previousHookStatusByPaneKey.get(paneKey)
if (
previous &&
refused.expectedStatus &&
previous.updatedAt <= refused.expectedStatus.receivedAt &&
previous.stateStartedAt === refused.expectedStatus.stateStartedAt &&
previous.agentType === refused.agent &&
agentProviderSessionsEqual(refused.agent, previous.providerSession, refused.providerSession)
) {
this.previousHookStatusByPaneKey.delete(paneKey)
}
}
protected readonly sleepResumeRecords: WorktreeSleepResumeRecorder = {
capture: (worktreeId, ptyIds) =>
captureWorktreeSleepResumeRecords({
@@ -58,7 +123,10 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu
}
/** Each pane's last hook status, which a status without a provider session inherits it from. */
private readonly previousHookStatusByPaneKey = new Map<string, ResumeRecordStatus>()
private readonly previousHookStatusByPaneKey = new Map<
string,
ResumeRecordStatus & { stateStartedAt?: number }
>()
private hookStatusResumeWrites: Promise<void> = Promise.resolve()
private readonly paneLaunchConfigOf: PaneLaunchConfigOf = (paneKey, agent) => {
@@ -80,7 +148,10 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu
if (!status) {
return
}
this.previousHookStatusByPaneKey.set(row.paneKey, status)
this.previousHookStatusByPaneKey.set(row.paneKey, {
...status,
stateStartedAt: row.stateStartedAt
})
this.inResumeRecordOrder(() => this.writeHookStatusResume(status)).catch((error) =>
console.warn('[runtime] could not record an agent resume record', error)
)
@@ -100,7 +171,19 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu
forgetHookStatusResume(clear: AgentStatusClearIpcPayload): void {
for (const [paneKey, status] of this.previousHookStatusByPaneKey) {
if (
'paneKey' in clear ? paneKey === clear.paneKey : status.connectionId === clear.connectionId
'paneKey' in clear
? paneKey === clear.paneKey &&
(!clear.refusedResume ||
(!!clear.refusedResume.expectedStatus &&
status.updatedAt <= clear.refusedResume.expectedStatus.receivedAt &&
status.stateStartedAt === clear.refusedResume.expectedStatus.stateStartedAt &&
status.agentType === clear.refusedResume.agent &&
agentProviderSessionsEqual(
status.agentType,
status.providerSession,
clear.refusedResume.providerSession
)))
: status.connectionId === clear.connectionId
) {
this.previousHookStatusByPaneKey.delete(paneKey)
}
@@ -5,6 +5,7 @@ import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout } from '../session
import type { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from '../session-tabs/structured-session-create-support'
import type { OrcaRuntimeWithGetPtyRecordForPaneKey } from '../orchestration/terminal-pane-mail-routing'
import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from '../terminal/wait/terminal-wait-visible-probe'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe {
protected abstract listResolvedWorktreeSnapshot(
@@ -42,6 +43,9 @@ export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeW
abstract onStructuredSessionStatusForMail(
...args: Parameters<OrcaRuntimeWithGetPtyRecordForPaneKey['onStructuredSessionStatusForMail']>
): ReturnType<OrcaRuntimeWithGetPtyRecordForPaneKey['onStructuredSessionStatusForMail']>
protected abstract assertStructuredChatAcquireAdmissible(
record: AgentSessionRecord
): Promise<void>
abstract isChatWorkerErrorEscalationEnabled(): boolean
abstract onStructuredSessionTabHidden(
...args: Parameters<OrcaRuntimeWithGetPtyRecordForPaneKey['onStructuredSessionTabHidden']>
@@ -290,6 +290,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}),
conversationNaming: nameChat,
onSessionTabHidden: (sessionId) => this.onStructuredSessionTabHidden(sessionId),
assertNoTerminalWriter: (record) => this.assertStructuredChatAcquireAdmissible(record),
attentionDelivery: createStructuredAttentionMobileDelivery({
readNotificationSettings: () => this.requireStore().getSettings().notifications,
readWorkspaceLabels: (scope) =>
+8
View File
@@ -6,6 +6,8 @@ import type {
import type { ResourceViewAdmission } from '../../shared/resource-view-admission'
import type { SpawnIntent } from '../../shared/host-spawn-admission'
import type {
SavedResumeDecision,
TerminalLaunchResult,
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
@@ -49,6 +51,7 @@ export type PtyApi = {
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
resumeOrigin?: 'saved'
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -97,6 +100,11 @@ export type PtyApi = {
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
startupCwdFallback?: { kind: 'worktree'; cwd: string }
agentResumeUnavailable?: true
agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable'
savedResumeDecision?: SavedResumeDecision
refusedResume?: TerminalLaunchResult['refusedResume']
launchToken?: string
resumeProviderSession?: TerminalLaunchResult['resumeProviderSession']
/** Host verdict on the shell-ready marker; absent when the execution host predates the field. */
shellReadyArmed?: boolean
}>
@@ -14,6 +14,8 @@ import type {
TerminalLeafMoveResult
} from '../../shared/terminal-leaf-move'
import type {
SavedResumeDecision,
TerminalLaunchResult,
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
@@ -41,6 +43,7 @@ export const ptySessionControlApi = {
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
resumeOrigin?: 'saved'
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -63,6 +66,7 @@ export const ptySessionControlApi = {
id: string
incarnationId?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
snapshot?: string
snapshotCols?: number
snapshotRows?: number
@@ -79,6 +83,11 @@ export const ptySessionControlApi = {
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
startupCwdFallback?: { kind: 'worktree'; cwd: string }
agentResumeUnavailable?: true
agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable'
savedResumeDecision?: SavedResumeDecision
refusedResume?: TerminalLaunchResult['refusedResume']
launchToken?: string
resumeProviderSession?: TerminalLaunchResult['resumeProviderSession']
shellReadyArmed?: boolean
}> => ipcRenderer.invoke('pty:spawn', opts),
write: (id: string, data: string, inputKind: TerminalInputKind): void => {
@@ -228,6 +228,16 @@ afterEach(() => {
})
}
it('renders the terminal refusal once beside the rejected message', async () => {
const fact: AgentSessionFailureFact = { kind: 'conversationOpenInTerminal' }
renderPane([rejected('terminal-held', 'Host fallback words.', fact)])
const row = await notice('terminal-held')
expect(row.textContent).toContain(
'Claude is already running this conversation in another terminal. Quit Claude there or close that terminal to continue here.'
)
expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull()
})
// The start row owns the failure reason; resending a rejected message creates a new message.
it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => {
mocks.journalItems = [startFailureRow(START_FAILED)]
@@ -89,6 +89,34 @@ afterEach(async () => {
})
describe('desktop words for a failure fact', () => {
it.each(['Claude', 'Codex'])(
'names %s in terminal refusals on every failure surface',
(agentName) => {
for (const surface of ['row', 'rejection', 'answer'] as const) {
expect(
agentSessionFailureSentence(
{ kind: 'conversationOpenInTerminal' },
surface,
{ agentName },
sayAgentSessionFailureTranslated
)
).toBe(
`${agentName} is already running this conversation in another terminal. Quit ${agentName} there or close that terminal to continue here.`
)
}
expect(
agentSessionFailureSentence(
{ kind: 'conversationOpenElsewhere' },
'row',
{ agentName: 'Codex' },
sayAgentSessionFailureTranslated
)
).toBe(
'Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.'
)
}
)
it.each([
['en', 'Then send your message again.'],
['es', 'Después, envía tu mensaje de nuevo.'],
@@ -149,6 +149,17 @@ const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopy
COPY.runtimeNotInstalled,
values
),
conversationOpenElsewhere: () =>
translate(
'components.native-chat.failureWords.conversationOpenElsewhere',
COPY.conversationOpenElsewhere
),
conversationOpenInTerminal: (values) =>
translate(
'components.native-chat.failureWords.conversationOpenInTerminal',
COPY.conversationOpenInTerminal,
values
),
chooseClaudeAccount: () =>
translate(
'components.native-chat.failureWords.chooseClaudeAccount',
@@ -173,7 +173,7 @@ describe('resumeAiVaultSessionInNewChat', () => {
[
'agent_session_conflict',
'conversationHeldElsewhere',
"Orca can't confirm Codex has exited in its terminal. Stop what's running there or close the terminal, then try again."
'Codex is already running this conversation in another terminal. Quit Codex there or close that terminal, then open this conversation from Session History.'
],
[
'agent_session_identity_required',
@@ -36,6 +36,14 @@ export async function reportCliChatResumeFailure(
)
return
}
// The chat's agent refused to start while the terminal may still write the conversation.
if (
refusal?.code === 'agent_session_operation_invalid' &&
refusal.details?.reason === 'conversationOpenInTerminal'
) {
toast.error(cliConversationExitGuidance(source.agent, true))
return
}
if (refusal?.code === 'agent_session_identity_required') {
if (refusal.details?.reason === 'transcriptNotFound') {
toast.error(
@@ -276,8 +276,8 @@ describe('CLI identity resume through the existing launch owner', () => {
})
await runCliChatResume(SOURCE, true)
expect(mocks.toast).toHaveBeenCalledTimes(1)
expect(mocks.toast.mock.calls[0]?.[0]).toMatch(
/^Orca can't confirm Claude.* has exited in its terminal\. Stop what's running there or close the terminal, then open this conversation from Session History\.$/
expect(mocks.toast.mock.calls[0]?.[0]).toBe(
'Claude is already running this conversation in another terminal. Quit Claude there or close that terminal, then open this conversation from Session History.'
)
expect(mocks.activate).not.toHaveBeenCalled()
expect(tabs()).toEqual([])

Some files were not shown because too many files have changed in this diff Show More