diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml index 72946ababa2..58033f26c4e 100644 --- a/.github/workflows/performance-contracts.yml +++ b/.github/workflows/performance-contracts.yml @@ -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' diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 48b32e5010a..ca53ed783dc 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -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', diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index df26313b144..fed668dfafd 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -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', diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts index edc920a84dc..74a2574046c 100644 --- a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts +++ b/mobile/src/session/mobile-native-chat-unsent-notices.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( { diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 67094fe706f..3881499c18f 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -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) diff --git a/src/main/ai-vault/structured-session-ownership.test-support.ts b/src/main/ai-vault/structured-session-ownership.test-support.ts index aec42fcf804..6da15e055b2 100644 --- a/src/main/ai-vault/structured-session-ownership.test-support.ts +++ b/src/main/ai-vault/structured-session-ownership.test-support.ts @@ -16,12 +16,12 @@ import { export const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' export function installOwnership( - overrides: Partial & { + overrides: Partial> & { handle?: AgentSessionProviderHandle } = {} ): void { const { handle, ...ownershipOverrides } = overrides - const ownership: StructuredProviderSessionOwnership = { + const ownership: Omit = { 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}`, diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 31d54b46854..098ef959aef 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -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[]) => { + 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(() => diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 9b6575a1e52..4f5688298b6 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -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() 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 ): StructuredProviderSessionOwnership | null { + return resumeOwnerships(args)[0] ?? null +} + +/** Every link naming the row, those matched by id first. */ +function resumeOwnerships( + args: Pick +): 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[] { diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index 9bb7211ef8e..7c9c1bc01d2 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -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) +}) diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 0e5ac2937c9..edc57c33dad 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -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 } diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts index b503581a20d..0914a451ea0 100644 --- a/src/main/ipc/pty-attach-view-joins-main-start.test.ts +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -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(), diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 703c154f1aa..9d52277fc57 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -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 } }) diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 342718ed9d1..b1e32e7347f 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -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() diff --git a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts new file mode 100644 index 00000000000..7143689445a --- /dev/null +++ b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts @@ -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 = 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}'`) + }) +}) diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts new file mode 100644 index 00000000000..af11bc574e6 --- /dev/null +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -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 +} + +type AdmissionRuntime = { + getAgentProviderSessionRows?(): AgentStatusIpcPayload[] + readSavedResumeSource?( + worktreeId: string, + paneKey: string + ): Promise + openAgentSessionRecordStore(): Promise +} + +/** `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 { + 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 { + 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 { + 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 { + 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[0], + paneKey: string | null, + admission: ResumeSpawnAdmission | null, + worktreeId?: string +): Promise { + 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 + } + } + : {}) + } +} diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 2a2ab7dd35a..958d637b8c1 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -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 { +): Promise { 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 } diff --git a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts index 8652384bf59..3463c55414a 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts @@ -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> | 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) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 9dbdf8a69d2..929a2aaf147 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -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 { 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) } } diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 6deaf690eed..c4c21359f31 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -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 diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts index b3fbeb2f011..c3967b6f187 100644 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -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 { 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 => { + 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 { 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) { diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index e654fcbb40c..e0c6011833f 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -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) { diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index d647bd172eb..7330cff6921 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -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 | 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, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 747c71b947d..02b9a9b39b7 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -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 diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index ce223be0e24..d3392389c76 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -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 { + 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) +} diff --git a/src/main/ipc/pty/pty-codex-account-attribution.test.ts b/src/main/ipc/pty/pty-codex-account-attribution.test.ts index 82f827b90ac..f6c0d5a401b 100644 --- a/src/main/ipc/pty/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty/pty-codex-account-attribution.test.ts @@ -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(), diff --git a/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts index 49da0a0c77d..4009fbb970b 100644 --- a/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts @@ -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(), diff --git a/src/main/ipc/pty/pty-pane-materialization-race.test.ts b/src/main/ipc/pty/pty-pane-materialization-race.test.ts index 3e59a0c6715..f15938f805f 100644 --- a/src/main/ipc/pty/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty/pty-pane-materialization-race.test.ts @@ -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', diff --git a/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts b/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts index ce1b52c56cb..49a58556ead 100644 --- a/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts +++ b/src/main/ipc/pty/pty-spawn-codex-home-unavailable.test.ts @@ -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(), diff --git a/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts index d5d3638de2e..2aa3a7fca83 100644 --- a/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty/pty-spawn-env-codex-resume-provenance.test.ts @@ -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(), diff --git a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts index d45811bb8bc..39a394cb546 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts @@ -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, diff --git a/src/main/ipc/pty/runtime/spawn-commit-response.test.ts b/src/main/ipc/pty/runtime/spawn-commit-response.test.ts index 2db2240f10e..c3935d76ea2 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-response.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-response.test.ts @@ -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() }) }) diff --git a/src/main/ipc/pty/runtime/spawn-commit-response.ts b/src/main/ipc/pty/runtime/spawn-commit-response.ts index 57f59601291..7831b1c3094 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-response.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-response.ts @@ -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. */ diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts index 594bf63a5ff..e18292ef4ae 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -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() diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index fc5abb29b66..e84ed72d37e 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -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 } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 107a8d7a647..98d78632fed 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -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 { 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 => { + 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) { diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts new file mode 100644 index 00000000000..afe5f1ad48f --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -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) => { + 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): 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' }) + } +}) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 503614c2cb7..6b0b6a742bc 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -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 diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index d764b1fc4a1..93bf1708104 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -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 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, diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index c7626872eb1..71e27d10d0d 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -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() diff --git a/src/main/ipc/pty/spawn-commit-assembly.ts b/src/main/ipc/pty/spawn-commit-assembly.ts index 5ed42acdd02..12762fb36a5 100644 --- a/src/main/ipc/pty/spawn-commit-assembly.ts +++ b/src/main/ipc/pty/spawn-commit-assembly.ts @@ -198,7 +198,7 @@ export async function commitPtySpawn( return resolvePaneSpawnReservation( state.paneSpawnReservationKey, state.paneSpawnReservation, - result + input.joinPayload.reply ) } } else if (input.earlyExit === 'throw') { @@ -225,7 +225,7 @@ function commitAdoptedAgentSession( input: PtySpawnCommitInput, adopted: AgentSessionClaimedSpawnResult, providerReattachLaunchIdentity: ReturnType -): Pick | Promise { +): PtySpawnResult | Promise { const { state } = input const { result } = state const runtime = state.deps.runtime @@ -272,9 +272,5 @@ function commitAdoptedAgentSession( state.paneSpawnReservation, adoptedResult ) - return { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - agentSessionEnsure: adopted - } + return adoptedResult } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 2cb505c7059..2ddef409a53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -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)) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts index 9b105cd29b0..5c4447b76a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts @@ -45,6 +45,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' | 'runtimeNotInstalled' + | 'conversationOpenInTerminal' > /** First-hand proof that acquisition spawned no process. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index e4f18317540..5b8a718ef95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -95,6 +95,9 @@ export type AttachFlowInput = { eventSink?: StructuredAgentSessionEventSink /** Prevent acquisition-window events from reaching superseded journals. */ onAcquiring?: () => Promise | void + /** Refuses the spawn while a terminal may still be writing the record's conversation. */ + assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise + /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void /** Adopt the conversation’s journal; attach never owns its open. */ openConversation: (record: AgentSessionRecord) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 9f180a6b64c..59ccc977485 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -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 }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index de2367ac757..de412f22ac7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -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 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 9f3b7dc5c66..643f6d1d0d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -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', { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index fedb1a28f64..ab3b8834980 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -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 + /** 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. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 50126091591..26e66bfacb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -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() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts new file mode 100644 index 00000000000..a66804cd1a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -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()), + 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(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 { + 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 = 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) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index 6991c55a3b2..f51ecc2ffb5 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -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)) + ) +} diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index 3c73592b1c5..69530bc3a29 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -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) }) diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index 64be4e40cad..a073cc0cbd5 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -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 diff --git a/src/main/native-chat/structured-conversation-key.ts b/src/main/native-chat/structured-conversation-key.ts new file mode 100644 index 00000000000..64dcc18d69d --- /dev/null +++ b/src/main/native-chat/structured-conversation-key.ts @@ -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) +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 19607c23010..a651ae7a65e 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -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 diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts index 152ad52a6f1..f72d4c589f2 100644 --- a/src/main/runtime/agent-session-record-store-slot.ts +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -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 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 + } +} diff --git a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts index d98cf1cf06d..c2644c8287b 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.test.ts @@ -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() diff --git a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts index 7a6e07428e0..5e128514908 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts @@ -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', diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 2d47a243d10..2e2c64da6f6 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -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 }) + .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 + } + } +) diff --git a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts index 7ac41fbbc0d..7e22efaa169 100644 --- a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts +++ b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts @@ -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() + } + } +) diff --git a/src/main/runtime/host-sleep-resume-record.test.ts b/src/main/runtime/host-sleep-resume-record.test.ts index 813aabd9727..37417aa4d41 100644 --- a/src/main/runtime/host-sleep-resume-record.test.ts +++ b/src/main/runtime/host-sleep-resume-record.test.ts @@ -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([ diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts index abe26419cfe..fb949ea8385 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts @@ -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 rows?: () => AgentStatusIpcPayload[] checkHookAgentPresence?: () => Promise<'live' | 'unverifiable' | 'exited' | null> listProcesses?: () => Promise @@ -81,7 +92,7 @@ function makeRuntime( ) => () => void } = {} ) { - const spawn = vi.fn<(args: unknown) => Promise<{ id: string }>>( + const spawn = vi.fn>( 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( diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts index 041c4465d81..1066394cbf8 100644 --- a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -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) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index e092e717664..daa977accea 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index 3a2a803a2de..adcb88d4960 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -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 { 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 + 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, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts index 3800fa08a05..345203f00ce 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts @@ -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 } diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 2e109eb2894..926b15d7e42 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -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 { + // 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 } diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts index f0aa1669b9e..c47af43d786 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts @@ -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((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() 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((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 }) diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts index 2a4f86faf09..88b0f516662 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts @@ -15,15 +15,22 @@ import { startDesktopMultiplexSubscribe } from './terminal-multiplex-test-harnes function startPendingPtyHarness() { const waits = new Map void }>() + const ptyIds = new Map() 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((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 diff --git a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts index 2996aecace6..973c9b7b461 100644 --- a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts @@ -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, diff --git a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts index 899b476e6ba..c3e62e7de85 100644 --- a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts +++ b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts @@ -25,6 +25,8 @@ export function stubRuntime(overrides: Partial = {}): 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: () => () => {}, diff --git a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts index b658e9814bc..04e00788ffc 100644 --- a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts +++ b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts @@ -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), diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 7a4cf3d4c33..23fe58fe067 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -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 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 resumeAutomaticWork?(): Promise diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 0f840595f04..65b5f63c0cd 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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 diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index cca1f51e750..b4841e2c8e1 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -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 diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index 7c94942e852..77964ef047a 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -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, diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts index a9761c36ad3..3c7ac8662dd 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts @@ -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')?.() diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts index 2a8a5ee4339..0c84b0a7e8e 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts @@ -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 } diff --git a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts index 88e6a7e840e..27d8413978c 100644 --- a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts +++ b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts @@ -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 } : {}) diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 0e89b6c3b53..5fe9dd7b78d 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -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 { + 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') { diff --git a/src/main/runtime/structured-agent-cli-writers.test.ts b/src/main/runtime/structured-agent-cli-writers.test.ts index d0bce827b70..d39ca3493b6 100644 --- a/src/main/runtime/structured-agent-cli-writers.test.ts +++ b/src/main/runtime/structured-agent-cli-writers.test.ts @@ -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() + const released = Promise.withResolvers() + 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')) diff --git a/src/main/runtime/structured-agent-cli-writers.ts b/src/main/runtime/structured-agent-cli-writers.ts index 0cf3524279b..7435c86085f 100644 --- a/src/main/runtime/structured-agent-cli-writers.ts +++ b/src/main/runtime/structured-agent-cli-writers.ts @@ -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)?.() ) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 5df3b112f68..57b4a548be8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -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, diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts index 4ef29120ac4..bb8464200a5 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts @@ -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 { diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index a5ecece68cb..d47e68c8c11 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -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 + 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, diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 1733517a025..25ed6b5bad7 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -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 - 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 } diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 8ac6e76af90..7a57eead259 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -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 } : {}) } diff --git a/src/main/runtime/terminal/launch/terminal-launch-result.ts b/src/main/runtime/terminal/launch/terminal-launch-result.ts new file mode 100644 index 00000000000..05a293f4d8a --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-launch-result.ts @@ -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>> + +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 +): 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 } : {}) + } +} diff --git a/src/main/runtime/terminal/lifecycle/pty-registration.ts b/src/main/runtime/terminal/lifecycle/pty-registration.ts index bfd04c40ab3..f0349df5dc9 100644 --- a/src/main/runtime/terminal/lifecycle/pty-registration.ts +++ b/src/main/runtime/terminal/lifecycle/pty-registration.ts @@ -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 diff --git a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts index dd6046f9917..2c2c24170ea 100644 --- a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts +++ b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts @@ -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, diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts index 9aa19c35b55..286b1f84059 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts @@ -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 { + 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 { + 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() + private readonly previousHookStatusByPaneKey = new Map< + string, + ResumeRecordStatus & { stateStartedAt?: number } + >() private hookStatusResumeWrites: Promise = 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) } diff --git a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts index 55a45addcb9..4cc34eedb4b 100644 --- a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts +++ b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts @@ -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 ): ReturnType + protected abstract assertStructuredChatAcquireAdmissible( + record: AgentSessionRecord + ): Promise abstract isChatWorkerErrorEscalationEnabled(): boolean abstract onStructuredSessionTabHidden( ...args: Parameters diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 2fdd71f4440..031a44664aa 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -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) => diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 7f6777ad8e6..dbdaa7fd897 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -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 }> diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 1111d7610bd..14ba5066532 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -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 => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx index b8c47c43746..26db3999303 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx @@ -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)] diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 75916328e7f..0b38825b0c7 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -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.'], diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 4e031d4c209..b1214aaa200 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -149,6 +149,17 @@ const PIECES: Record + 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', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index f2f8d396285..7d70bc5eb56 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -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', diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 2b2949f763f..1807c15f9c3 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -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( diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 646c30fab6b..4a7b5d3f965 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -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([]) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index ab48b74754c..0a00ea7b085 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { PtyConnectResult } from './pty-transport-types' @@ -7,8 +8,11 @@ export function projectIpcPtyConnectResult( spawnResult: IpcPtySpawnResult ): string | PtyConnectResult { const launchAgent = isTuiAgent(spawnResult.launchAgent) ? spawnResult.launchAgent : undefined + const facts = terminalLaunchFacts({ ...spawnResult, launchAgent }) if (spawnResult.isReattach || spawnResult.coldRestore || spawnResult.sessionExpired) { return { + ...facts, + ...(spawnResult.incarnationId ? { incarnationId: spawnResult.incarnationId } : {}), id: spawnResult.id, ...(spawnResult.isReattach ? { isReattach: true } : {}), ...(launchAgent ? { launchAgent } : {}), @@ -40,23 +44,34 @@ export function projectIpcPtyConnectResult( ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } if ( launchAgent || spawnResult.launchConfig || spawnResult.startupCwdFallback || - spawnResult.agentResumeUnavailable + spawnResult.agentResumeUnavailable || + spawnResult.savedResumeDecision || + spawnResult.refusedResume || + spawnResult.launchToken ) { return { + ...facts, + ...(spawnResult.incarnationId ? { incarnationId: spawnResult.incarnationId } : {}), id: spawnResult.id, ...(launchAgent ? { launchAgent } : {}), ...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}), ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } return spawnResult.id diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 8b4deb81563..e70d6cfc36f 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { readIpcErrorDetail } from '@/lib/ipc-error' @@ -132,7 +133,17 @@ export async function connectIpcPty( if (!context.isExpectedExitCurrent()) { return } - return { id: spawnResult.id, exitedBeforeAttach: true } + return { + ...terminalLaunchFacts(spawnResult), + id: spawnResult.id, + exitedBeforeAttach: true, + // Why kept: a shell that replaced a resume and then exited never resumed the agent, and a + // refused plan must still not be registered or consume the saved session. + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) + } } if (context.isDestroyed()) { return diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index ddb61c61ccc..eebe524441e 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -75,6 +75,7 @@ export async function spawnIpcPty( commandDelivery, launchConfig, resumeProviderSession, + resumeOrigin, launchToken, launchAgent, startupCommandDelivery, @@ -113,6 +114,9 @@ export async function spawnIpcPty( ...((connectOptions.resumeProviderSession ?? resumeProviderSession) ? { resumeProviderSession: connectOptions.resumeProviderSession ?? resumeProviderSession } : {}), + ...(resumesAgent && (connectOptions.resumeOrigin ?? resumeOrigin) + ? { resumeOrigin: connectOptions.resumeOrigin ?? resumeOrigin } + : {}), ...((connectOptions.launchToken ?? launchToken) ? { launchToken: connectOptions.launchToken ?? launchToken } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts index 768b275b136..927dbc21746 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts @@ -21,6 +21,8 @@ import { import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' +import type { AgentStatusSlice } from '@/store/slices/agent-status-slice-contract' +import { normalizeLaunchConfigRegistrationMetadata } from '@/store/slices/agent-status-launch-config' import type { MockTransport } from './pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -216,6 +218,17 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + const registerLaunchConfig: AgentStatusSlice['registerAgentLaunchConfig'] = ( + key, + config, + metadata + ) => { + mockStoreState.agentLaunchConfigByPaneKey[key] = { + launchConfig: config, + identity: normalizeLaunchConfigRegistrationMetadata(key, metadata) + } + } + mockStoreState.registerAgentLaunchConfig.mockImplementation(registerLaunchConfig) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts index c942c385d34..0fea01af354 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { + expectNoWindowStart, expectWindowPaintsColdRestoreOnly, mainLayoutApplyTransport } from './pty-connection-test-main-pane-start-fixtures' @@ -365,6 +366,9 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { agentCmdOverrides: {} }, agentStatusByPaneKey: {}, + consumeTabStartupCommand: vi.fn(), + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, sleepingAgentSessionsByPaneKey: { [paneKey]: { paneKey, @@ -378,7 +382,7 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { updatedAt: 1 } } - } as StoreState + } const pane = createPane(1) const manager = createManager(1) @@ -392,13 +396,20 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { await vi.advanceTimersByTimeAsync(70) if (ORCA_LAYOUT_OWNER_IS_MAIN) { - // Main resumes the agent; the window attaches, paints the host's history, and resumes nothing. - expectWindowPaintsColdRestoreOnly(pane, transport, deps, mockStoreState, 'lost-pty') - return + expectNoWindowStart(transport, mockStoreState) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'lost-pty', attachOnlyView: true }) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) + expect(transport.sendInput).not.toHaveBeenCalled() } expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable') + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index eda161456ba..c89990da68e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -102,6 +102,7 @@ export type StoreState = { removeDeferredSshSessionId: ReturnType consumePendingColdRestore: ReturnType consumePendingSnapshot: ReturnType + consumeTabStartupCommand?: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record retainedAgentsByPaneKey: Record diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 0968aaaffb0..deee26142a8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -11,6 +11,7 @@ import type { StartupCommandDelivery } from '../../../../shared/codex-startup-de import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { + SavedResumeStartupSource, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -29,6 +30,9 @@ export type PtyPaneStartup = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: a resume built from saved facts, which the host refuses for a chat's conversation. */ + resumeOrigin?: 'saved' + resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index 0cabd2f14ff..c6add42bf18 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -1,3 +1,7 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { POST_REPLAY_MODE_RESET, @@ -249,18 +253,37 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) - // Why: main started the pane and its agent; the window only paints it. - const preparedStartup = mainStartsPane(session) - ? null - : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) - const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (ctx.connectResult.agentResumeUnavailable) { - // Dropped resume argv starts a new session; the restored banner must not claim the old session returned. - session.showSessionRestoredBanner('resume-unavailable') - } else if (preparedStartup?.hasSleepingRecord) { - session.showSessionRestoredBanner() + // An existing owner permits history replay, not another saved continuation. + let resumeUnavailable = Boolean(ctx.connectResult.agentResumeUnavailable) + let preparedStartup = + mainStartsPane(session) || + ctx.connectResult.isReattach === true || + ctx.connectResult.agentResumeRefused || + resumeUnavailable + ? null + : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) + if (preparedStartup && !ctx.coldRestoreStartup) { + const rejected = applyTerminalLaunchResult( + session, + ctx.connectResult, + captureTerminalLaunchContext(session, preparedStartup), + 'candidate' + ) + if (rejected) { + resumeUnavailable = true + preparedStartup = null } + } + const didPrepareResume = + ctx.connectResult.isReattach !== true && + session.applyColdRestoreAgentResumeStartup(preparedStartup) + if (resumeUnavailable) { + // Dropped resume argv starts a new session; the restored banner must not claim the old session returned. + session.showSessionRestoredBanner('resume-unavailable') + } else if (didPrepareResume && preparedStartup?.hasSleepingRecord) { + session.showSessionRestoredBanner() + } + if (didPrepareResume) { session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) } // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts index 1ca88396138..e983d9b56c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -16,10 +16,13 @@ import { normalizeAgentProviderSession } from '../../../../../shared/agent-session-resume' +import type { HeldPaneReopenOutcome } from '../newer-daemon-hold-state' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' +export const NOT_REOPENED: HeldPaneReopenOutcome = { reopened: false, agentResumed: false } + type AppState = ReturnType /** The agent session a cold restore of this pane would resume; read-only, so pre-spawn checks can share it. */ @@ -109,6 +112,24 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe }, launchConfig: startupPlan.launchConfig, resumeProviderSession: providerSession, + resumeOrigin: 'saved', + resumeSource: { + cancelled: false, + ...(sleepingRecord && + sleepingRecord.agent === agent && + agentProviderSessionsEqual(agent, sleepingRecord.providerSession, providerSession) + ? { record: sleepingRecord } + : {}), + ...(useLiveEntry && entry + ? { + expectedStatus: { + paneKey: session.cacheKey, + receivedAt: entry.updatedAt, + stateStartedAt: entry.stateStartedAt + } + } + : {}) + }, launchToken: coldRestoreLaunchToken, useLiveEntry: Boolean(useLiveEntry), hasSleepingRecord: Boolean(sleepingRecord), @@ -116,3 +137,26 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe } } } + +/** Keeps the reattach fallback's actual fresh-agent outcome with the pane's start. */ +export function reopenPaneAfterMissingReattach( + session: ConnectPanePtySession, + startup: ColdRestoreAgentResumeStartup | null, + resumedAgent: (ptyId: string) => boolean +): Promise { + let resumeUnavailable = false + return session + .startFreshColdRestoreAgentResume(startup, { + forceBlankRestoredViewport: true, + onAgentResumeUnavailable: () => { + resumeUnavailable = true + } + }) + .then( + (ptyId: string | null) => ({ + reopened: ptyId !== null, + agentResumed: ptyId !== null && resumedAgent(ptyId) && !resumeUnavailable + }), + () => NOT_REOPENED + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 2aa5c122870..9cc95ec6fd7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -133,6 +133,9 @@ export function connectPanePty( session.waitTeardowns = [] // Consume startup once from this shallow-copied deps; callers must clear their separate outer reference. session.paneStartup = session.deps.startup ?? null + if (session.paneStartup?.resumeOrigin === 'saved') { + session.paneStartup.resumeSource ??= { cancelled: false } + } session.deps.startup = undefined // Hold replacement PTY retirement until main takes it in a spawn request or disposal kills it. session.pendingReplacedPtyId = session.deps.replacesPtyId ?? null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 713c717c494..593f10738d6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -28,16 +28,28 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio session.applyColdRestoreAgentResumeStartup = ( startup: ColdRestoreAgentResumeStartup | null ): boolean => { - if (!startup) { + if (!startup || startup.resumeSource?.cancelled) { return false } const state = useAppStore.getState() + if (!session.pendingStartupCommand && session.shouldDeliverStartupViaTerminalPaste) { + session.pendingStartupCommand = { + command: startup.command, + resumeSource: startup.resumeSource + } + } state.registerAgentLaunchConfig(session.cacheKey, startup.launchConfig, { agentType: startup.agent, launchToken: startup.launchToken, tabId: session.deps.tabId, leafId: session.pane.leafId }) + if (startup.resumeSource) { + session.savedStartupLaunchRegistration = { + source: startup.resumeSource, + entry: useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + } + } return true } session.clearSleepingRecordAfterColdRestoreSpawn = ( @@ -75,6 +87,9 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(), options: FreshSpawnOptions = {} ): Promise => { + if (startup?.resumeSource?.cancelled) { + return Promise.resolve(null) + } const mainPane = mainResumedSleepPane(session, startup) if (mainPane && startup?.sleepingRecordEntry) { return attachToMainResumedSleepPane( @@ -107,7 +122,10 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio !session.disposed && session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && session.transport.getPtyId() === ptyId - const runTerminalPasteStartupCommand = async (command: string): Promise => { + const runTerminalPasteStartupCommand = async ( + command: string, + current: () => boolean + ): Promise => { const ptyId = session.transport.getPtyId() const result = await executeTerminalStartupCommandPaste({ command, @@ -120,43 +138,52 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio isWindowsConpty: session.isNativeWindowsConpty }), transport: session.transport, - isTargetCurrent: isStartupPasteTargetCurrent + isTargetCurrent: (target) => current() && isStartupPasteTargetCurrent(target) }) - if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { + if (result.status !== 'pasted' || !current() || !isStartupPasteTargetCurrent(ptyId)) { return false } return session.transport.sendInput('\r', 'launch') } session.schedulePendingStartupCommandDelivery = (): void => { const startup = session.pendingStartupCommand - if (!startup) { + if (!startup || startup.resumeSource?.cancelled) { return } if (session.startupInjectTimer !== null) { clearTimeout(session.startupInjectTimer) } + const generation = session.transportStreamGeneration + const isCurrent = (): boolean => + !session.disposed && + session.pendingStartupCommand === startup && + !startup.resumeSource?.cancelled && + generation === session.transportStreamGeneration && + (startup.isCurrent?.() ?? true) session.startupInjectTimer = setTimeout(() => { session.startupInjectTimer = null void (async () => { - if (session.pendingStartupCommand !== startup || session.disposed) { + if (!isCurrent()) { return } if (session.shouldDeliverStartupViaTerminalPaste) { await waitForTerminalOutputParsed(session.pane.terminal) } - if (session.pendingStartupCommand !== startup || session.disposed) { + if (!isCurrent()) { return } const command = startup.command const submitted = session.shouldDeliverStartupViaTerminalPaste - ? await runTerminalPasteStartupCommand(command) + ? await runTerminalPasteStartupCommand(command, isCurrent) : session.transport.sendInput(`${command}\r`, 'launch') if (submitted) { session.armStartupDraftReadinessObservation() } else { session.releaseUnattemptedStartupDraftPasteDelivery() } - session.pendingStartupCommand = null + if (session.pendingStartupCommand === startup) { + session.pendingStartupCommand = null + } })() }, 50) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts index e844f90a9f0..ef0d999be5e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -1,3 +1,4 @@ +import { NOT_REOPENED, reopenPaneAfterMissingReattach } from './cold-restore-resume-startup' import { writeStartupCwdFallbackNotice } from './startup-cwd-fallback-notice' import { armPaneOwnerRetry } from './pane-owner-recovery' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' @@ -65,8 +66,6 @@ async function prepareDeferredSessionReattach( void connectDeferredSessionReattach(session, deferredReattachSessionId, options) } -const NOT_REOPENED: HeldPaneReopenOutcome = { reopened: false, agentResumed: false } - /** Resolves to what the reattach did, which a held pane's release reports. */ function connectDeferredSessionReattach( session: ConnectPanePtySession, @@ -119,7 +118,8 @@ function connectDeferredSessionReattach( session.reportError(message) }, // Why the resume even under main: it is what main ran, so a capacity retry resumes it again. - toProcessExitStartup(resumeStartup ?? session.paneStartup) + toProcessExitStartup(resumeStartup ?? session.paneStartup), + resumeStartup ?? session.paneStartup ) session.beginReattachLiveDataDeferral(outputCallbacks.generation) session.transportConnectInFlightSince = Date.now() @@ -136,6 +136,7 @@ function connectDeferredSessionReattach( ...(coldRestoreStartup?.resumeProviderSession ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } : {}), + ...(coldRestoreStartup ? { resumeOrigin: coldRestoreStartup.resumeOrigin } : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), @@ -147,15 +148,7 @@ function connectDeferredSessionReattach( session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && outputCallbacks.generation === session.transportStreamGeneration const startFreshInstead = (): Promise => - session - .startFreshColdRestoreAgentResume(coldRestoreStartup, { forceBlankRestoredViewport: true }) - .then( - (ptyId: string | null) => ({ - reopened: ptyId !== null, - agentResumed: ptyId !== null && resumedAgent(ptyId) - }), - () => NOT_REOPENED - ) + reopenPaneAfterMissingReattach(session, coldRestoreStartup, resumedAgent) void Promise.resolve(reattachPromise) .catch(() => null) @@ -242,7 +235,8 @@ function connectDeferredSessionReattach( result, deferredReattachSessionId, coldRestoreStartup, - outputCallbacks.generation + outputCallbacks.generation, + outputCallbacks.launchContext ) if (accepted && isCurrentReattach()) { writeStartupCwdFallbackNotice(session, result) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts index 013b6a30723..7a2a09ad6f1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts @@ -7,6 +7,7 @@ import { NEWER_DAEMON_HOLD_ERROR } from '../../../../../shared/newer-daemon-hold import { newerDaemonHoldStore, reopenHeldTerminals } from '../newer-daemon-hold-state' import { startDeferredSessionReattach } from './deferred-session-reattach-connect' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import type { FreshSpawnOptions } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { PANE_NOT_LIVE_ERROR } from '../../../../../shared/pane-attach-only-view' import { useAppStore } from '@/store' @@ -64,7 +65,9 @@ function createSession(opts: { runtimeEnvironmentId?: string } = {}) { mergeStartupEnvWithPaneIdentity: (env: Record) => env, handleReattachResult: vi.fn().mockResolvedValue(true), clearExitedPanePtyLayoutBinding: vi.fn(), - startFreshColdRestoreAgentResume: vi.fn().mockResolvedValue('fresh-pty'), + startFreshColdRestoreAgentResume: vi + .fn<(startup: unknown, options?: FreshSpawnOptions) => Promise>() + .mockResolvedValue('fresh-pty'), reportError: vi.fn(), cols: 80, rows: 24, @@ -154,15 +157,27 @@ describe('restored pane refused for a newer daemon', () => { expect(fixture.writeReplayData).not.toHaveBeenCalled() }) - it('counts a released pane that falls back to a fresh session as reopened', async () => { - const { fixture, session, nextConnect } = createSession() - startDeferredSessionReattach(session, 'restored-pty') - await vi.waitFor(() => expect(isHeld()).toBe(true)) - nextConnect('throw') - await reopenHeldTerminals('tab:leaf') - expect(fixture.startFreshColdRestoreAgentResume).toHaveBeenCalledOnce() - expect(toast.success).toHaveBeenCalledWith('Reopened 1 terminal', windowResumedDescription) - }) + it.each([false, true])( + 'counts the fallback terminal without claiming an unavailable resume (%s)', + async (unavailable) => { + const { fixture, session, nextConnect } = createSession() + startDeferredSessionReattach(session, 'restored-pty') + await vi.waitFor(() => expect(isHeld()).toBe(true)) + fixture.startFreshColdRestoreAgentResume.mockImplementation(async (_startup, options) => { + if (unavailable) { + options?.onAgentResumeUnavailable?.() + } + return 'fresh-pty' + }) + nextConnect('throw') + await reopenHeldTerminals('tab:leaf') + expect(fixture.startFreshColdRestoreAgentResume).toHaveBeenCalledOnce() + expect(toast.success).toHaveBeenCalledWith( + 'Reopened 1 terminal', + unavailable ? undefined : windowResumedDescription + ) + } + ) it.each([ ['restored', { description: '1 agent session resumed.' }], diff --git a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts index 3c44157a66e..dc96b8fdb77 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts @@ -25,6 +25,7 @@ function resumeStartup( return { command: 'codex resume conv-1', agent: 'codex', + resumeOrigin: 'saved', resumeProviderSession: { key: 'session_id', id: 'conv-1' }, launchConfig: { agentArgs: '', agentEnv: {} }, launchToken: 'token-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index e658dba14eb..2abd01112ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -1,3 +1,4 @@ +import { applyTerminalLaunchResult } from './terminal-launch-result-policy' import { armPaneOwnerRetry, isPaneOwnerRefusal } from './pane-owner-recovery' import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' @@ -47,6 +48,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { releaseDeferredCwdFence() return Promise.resolve(null) } + const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride + if (effectiveStartup?.resumeSource?.cancelled) { + releaseDeferredCwdFence() + return Promise.resolve(null) + } session.authoritativeReattachGeneration += 1 // Every connect creates/rebinds ownership; legacy replies lacking incarnationId cannot inherit the old fence. session.remotePtyIncarnationId = null @@ -77,14 +83,14 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.transportConnectInFlightSince = Date.now() session.pendingSpawnDeferral = null const visibleAtSpawn = session.deps.isVisibleRef.current === true - const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride let ownerRefused = false const outputCallbacks = session.captureTransportOutputCallbacks( (message: string) => { ownerRefused ||= isPaneOwnerRefusal(message) session.reportError(message) }, - toProcessExitStartup(coldRestoreOverride ?? effectiveStartup) + toProcessExitStartup(coldRestoreOverride ?? effectiveStartup), + coldRestoreOverride ?? effectiveStartup ) const spawnedRaw = session.transport.connect({ url: '', @@ -96,7 +102,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { : {}), ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), ...(coldRestoreOverride - ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } + ? { + resumeProviderSession: coldRestoreOverride.resumeProviderSession, + resumeOrigin: coldRestoreOverride.resumeOrigin + } : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), @@ -149,12 +158,19 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // Legacy replies may omit incarnationId; remote evidence remains unverifiable until a stamped attach result. session.remotePtyIncarnationId = connectResult?.incarnationId ?? null if (connectResult?.isReattach) { - session.pendingStartupCommand = null + if ( + session.pendingStartupCommand?.resumeSource === effectiveStartup?.resumeSource && + (effectiveStartup?.resumeSource || + session.pendingStartupCommand?.command === effectiveStartup?.command) + ) { + session.pendingStartupCommand = null + } const accepted = await session.handleReattachResult( connectResult, null, coldRestoreOverride, - outputCallbacks.generation + outputCallbacks.generation, + outputCallbacks.launchContext ) session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise @@ -169,11 +185,20 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } return accepted ? resolvedPtyId : null } - if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { - session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) - }) + const rejectedSavedStartup = connectResult + ? applyTerminalLaunchResult( + session, + connectResult, + outputCallbacks.launchContext, + 'creator' + ) + : false + const coldRestore = rejectedSavedStartup ? null : coldRestoreOverride + if ( + connectResult?.agentResumeRefused === 'unverifiable' && + !(coldRestoreOverride ?? effectiveStartup)?.resumeSource?.record + ) { + session.keepUnrunResumeHandle(startupOverride) } if (resolvedPtyId) { if ( @@ -191,17 +216,17 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { spawnedPtyId.agentResumeUnavailable ) { // Dropped resume argv starts a new session; do not claim the previous session was restored. - session.showSessionRestoredBanner('resume-unavailable') - } else if (coldRestoreOverride?.hasSleepingRecord) { + options.onAgentResumeUnavailable?.() + } else if (coldRestore?.hasSleepingRecord) { session.showSessionRestoredBanner() } - session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + session.clearSleepingRecordAfterColdRestoreSpawn(coldRestore) } else if ( session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) ) { // Unbound spawns are no delivery target; clear launch registration so delayed drafts cannot reach a future pane. - session.clearRegisteredStartupLaunchConfig() + session.clearRegisteredStartupLaunchConfig(coldRestoreOverride ?? effectiveStartup) } if ( resolvedPtyId && @@ -250,7 +275,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) ) { - session.clearRegisteredStartupLaunchConfig() + session.clearRegisteredStartupLaunchConfig(coldRestoreOverride ?? effectiveStartup) } clearPreSignaledSerializer() return null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts index 4bb06f8dbde..f360309d461 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts @@ -1,22 +1,29 @@ import type { AgentProviderSessionMetadata, ResumableTuiAgent, + SavedResumeStartupSource, SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' export type PendingStartupCommand = { command: string + resumeSource?: SavedResumeStartupSource + isCurrent?: () => boolean env?: Record } export type FreshSpawnOptions = { forceBlankRestoredViewport?: boolean + /** Main started the pane without resuming the requested agent session. */ + onAgentResumeUnavailable?: () => void } export type ColdRestoreAgentResumeStartup = PendingStartupCommand & { agent: ResumableTuiAgent resumeProviderSession: AgentProviderSessionMetadata + /** Built from the pane's saved facts, so the host refuses it for a chat's conversation. */ + resumeOrigin: 'saved' launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts index d02ff702baa..b6bf9f7b2ff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts @@ -32,6 +32,9 @@ export function deferHiddenAgentResume( ptyId: session.transport.getPtyId(), record: recordEntry.record, wake: (): Promise => { + if (startup?.resumeSource?.cancelled) { + return Promise.resolve(null) + } session.hiddenAgentResumeReleased = true return start().then((ptyId) => { // Why the transport: a held reattach resolves before it binds, and the hold must cover its fallback. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index a5e016e3742..5acf2175828 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -59,9 +59,10 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { // Providers own startup delivery across renderer replacement; only explicit terminal paste stays renderer-owned. session.pendingStartupCommand = session.shouldDeliverStartupViaTerminalPaste ? session.paneStartup?.command - ? { command: session.paneStartup.command } + ? { command: session.paneStartup.command, resumeSource: session.paneStartup.resumeSource } : null : null + const startupSource = session.paneStartup?.resumeSource const startupDraftReadyScanner = session.ownsStartupDraftPaste ? createDraftPasteReadyScanner( session.startupDraftAgentConfig?.draftPasteReadySignal ?? @@ -89,6 +90,7 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { const ptyId = session.transport.getPtyId() if ( !ptyId || + startupSource?.cancelled || session.disposed || session.deps.paneTransportsRef.current.get(session.pane.id) !== session.transport ) { @@ -115,6 +117,9 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { session.cleanupStartupDraftPasteTimers() // Share the transport queue with focus replies so ConPTY cannot race CSI I into the draft as a literal prefix. void sendAgentDraftPasteContent(ptyId, session.startupDraftPrompt, 'launch', async (data) => { + if (getStartupDraftPtyId() !== ptyId) { + return false + } const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') if (accepted && !startupDraftInputRecorded) { // Transport paste bypasses xterm’s input signal; record it so hibernation cannot discard the composed draft. @@ -155,6 +160,9 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { } startupDraftHardTimer = setTimeout(() => { startupDraftHardTimer = null + if (!getStartupDraftPtyId()) { + return + } if (session.startupDraftAgent === 'codex') { startupDraftPasteSettled = true // A timed-out launch stays consumed across pane disposal and remount. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts index 70ccdf7ab61..e32d966d1d6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -81,6 +81,9 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt ...(session.paneStartup?.resumeProviderSession ? { resumeProviderSession: session.paneStartup.resumeProviderSession } : {}), + ...(session.paneStartup?.resumeOrigin + ? { resumeOrigin: session.paneStartup.resumeOrigin } + : {}), ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) ? { agentPrompt: diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index cbf1ede5197..45735331642 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' import { createPane } from '../pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -6,6 +7,7 @@ import { } from '../pty-connection-test-environment' import { createReattachPayloadHandlers } from './apply-reattach-payload' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { mainStartsPane } from './main-starts-pane' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { ReattachPayloadContext } from './reattach-payload-context' import type { ReattachPayloadSession } from './reattach-payload-session' @@ -83,7 +85,7 @@ describe('reattach payload context', () => { sleepingRecordEntry: null } as ColdRestoreAgentResumeStartup const buildColdRestoreAgentResumeStartup = vi.fn() - const applyColdRestoreAgentResumeStartup = vi.fn(() => true) + const applyColdRestoreAgentResumeStartup = vi.fn((value) => Boolean(value)) const schedulePendingStartupCommandDelivery = vi.fn() const session = createSession({ buildColdRestoreAgentResumeStartup, @@ -107,7 +109,9 @@ describe('reattach payload context', () => { if (ORCA_LAYOUT_OWNER_IS_MAIN) { // Main started the pane and resumed its agent; the window's paint applies no resume. - expect(applyColdRestoreAgentResumeStartup).not.toHaveBeenCalledWith(startup) + expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledExactlyOnceWith(null) + expect(session.clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() + expect(session.showSessionRestoredBanner).not.toHaveBeenCalled() } else { expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(startup) } @@ -116,3 +120,147 @@ describe('reattach payload context', () => { expect(context.reattachPayloadApplied).toBe(true) }) }) + +it.each(['missing', 'different', 'admitted'] as const)( + 'authorizes only the checked late saved candidate (%s)', + async (kind) => { + await installTerminalTestGlobals() + try { + const startup: ColdRestoreAgentResumeStartup = { + command: 'codex resume C', + agent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: 'C' }, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + launchToken: 'saved-C', + resumeSource: { cancelled: false }, + useLiveEntry: false, + hasSleepingRecord: false, + sleepingRecordEntry: null + } + const apply = vi.fn((value) => Boolean(value)) + const cancel = vi.fn() + const schedule = vi.fn() + const session = createSession({ + // Main can hand a twin's start back to this renderer-owned launch path. + mainPaneStartHandedBack: true, + buildColdRestoreAgentResumeStartup: () => startup, + applyColdRestoreAgentResumeStartup: apply, + cancelSavedResumeStartup: cancel, + schedulePendingStartupCommandDelivery: schedule, + clearSleepingRecordAfterColdRestoreSpawn: vi.fn(), + showSessionRestoredBanner: vi.fn(), + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + expect(mainStartsPane(session)).toBe(false) + const context = createContext('', 1) + context.connectResult = { + id: 'pty-1', + coldRestore: { scrollback: '', cwd: '/workspace' }, + ...(kind === 'missing' + ? {} + : { + savedResumeDecision: { + agent: 'codex', + providerSession: { key: 'session_id', id: kind === 'different' ? 'D' : 'C' }, + verdict: 'admitted' + } + }) + } + await createReattachPayloadHandlers(session, context).applyReattachPayload() + expect(apply).toHaveBeenCalledWith(kind === 'admitted' ? startup : null) + expect(schedule).toHaveBeenCalledTimes(kind === 'admitted' ? 1 : 0) + expect(cancel).toHaveBeenCalledTimes(kind === 'admitted' ? 0 : 1) + } finally { + await restoreTerminalTestGlobals() + } + } +) + +it.each(['missing', 'admitted', 'prepared'] as const)( + 'observes a cached cold owner without consuming an uncorrelated newer recipe (%s)', + async (kind) => { + await installTerminalTestGlobals() + const record = { + paneKey: 'tab:leaf', + worktreeId: 'wt', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + state: 'done' as const, + prompt: '', + capturedAt: 20, + updatedAt: 20 + } + const previousRecords = useAppStore.getState().sleepingAgentSessionsByPaneKey + useAppStore.setState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }) + try { + const startup: ColdRestoreAgentResumeStartup = { + command: 'codex resume C', + agent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: record.providerSession, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + launchToken: 'replacement-token', + useLiveEntry: false, + hasSleepingRecord: true, + sleepingRecordEntry: { paneKey: record.paneKey, record } + } + const session = createSession({ + cacheKey: record.paneKey, + buildColdRestoreAgentResumeStartup: vi.fn(() => startup), + applyColdRestoreAgentResumeStartup: vi.fn(() => true), + cancelSavedResumeStartup: vi.fn(), + schedulePendingStartupCommandDelivery: vi.fn(), + clearSleepingRecordAfterColdRestoreSpawn: vi.fn(), + showSessionRestoredBanner: vi.fn(), + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + const context: ReattachPayloadContext = { + ...createContext('', 1), + connectResult: { + id: 'pty-1', + isReattach: true, + coldRestore: { scrollback: 'cached history', cwd: '/workspace' }, + ...(kind === 'admitted' + ? { + savedResumeDecision: { + agent: 'codex', + providerSession: record.providerSession, + verdict: 'admitted' + } + } + : {}) + }, + coldRestoreStartup: kind === 'prepared' ? startup : null + } + await createReattachPayloadHandlers(session, context).applyReattachPayload() + expect(session.writeReplayData).toHaveBeenCalledWith( + expect.stringContaining('cached history') + ) + expect(window.api.pty.ackColdRestore).toHaveBeenCalledOnce() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('pty-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: session.pane.leafId + }) + } else { + expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('pty-1') + } + expect(context.reattachPayloadApplied).toBe(true) + expect(session.buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(session.applyColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(session.cancelSavedResumeStartup).not.toHaveBeenCalled() + expect(session.schedulePendingStartupCommandDelivery).not.toHaveBeenCalled() + expect(session.clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() + expect(session.showSessionRestoredBanner).not.toHaveBeenCalled() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + expect(window.api.pty.write).not.toHaveBeenCalled() + } finally { + useAppStore.setState({ sleepingAgentSessionsByPaneKey: previousRecords }) + await restoreTerminalTestGlobals() + } + } +) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index 7f30f25f99f..88ff91e770a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -2,6 +2,9 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' export type ReattachPayloadSession = Pick< ConnectPanePtySession, + | 'cacheKey' + | 'paneStartup' + | 'registerEffectiveLaunchConfig' | 'applyColdRestoreAgentResumeStartup' | 'applySnapshotKittyKeyboardModes' | 'buildColdRestoreAgentResumeStartup' @@ -18,6 +21,7 @@ export type ReattachPayloadSession = Pick< | 'recordRendererOrderedSeq' | 'rememberReattachPayloadAgentSignal' | 'schedulePendingStartupCommandDelivery' + | 'cancelSavedResumeStartup' | 'sendFocusedReattachFocusInAfterReplay' | 'setRestoredSnapshotBaseline' | 'shouldPreserveAgentReattachModes' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts index 0597d1c45ac..c83223c5b15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts @@ -12,7 +12,12 @@ const REMOTE_PTY_ID = 'remote:env-1@@pty-1' const HOST_IMAGE = { data: 'PROMPT $ ', cols: 80, rows: 24, seq: 7, source: 'headless' as const } const mocks = vi.hoisted(() => { - const state: Record = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + const state: Record = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } const capturedContexts: ReattachPayloadContext[] = [] const callOrder: string[] = [] return { state, capturedContexts, callOrder } @@ -141,7 +146,12 @@ function decidedContext(): ReattachPayloadContext { beforeEach(() => { vi.clearAllMocks() - mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + mocks.state = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } mocks.capturedContexts = [] mocks.callOrder = [] }) @@ -333,6 +343,7 @@ describe('handleReattachResult empty reattach under a hibernation note', () => { const startup: ColdRestoreAgentResumeStartup = { command: 'codex resume conv-1', agent: 'codex', + resumeOrigin: 'saved', resumeProviderSession: record.providerSession, launchConfig: { agentArgs: '', agentEnv: {} }, launchToken: 'token-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index 77be616b605..f5e917963bc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -1,3 +1,8 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext, + type TerminalLaunchContext +} from './terminal-launch-result-policy' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport-types' @@ -45,6 +50,7 @@ type ReattachResultSession = ReattachPayloadSession & | 'handleReattachResult' | 'mountFollowsTerminalPark' | 'registerEffectiveLaunchConfig' + | 'cancelSavedResumeStartup' | 'registerPaneSerializerFor' | 'registerSideEffectFactConsumerForPty' | 'reportPanePtyVisibility' @@ -69,7 +75,11 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi result: PtyConnectResult | string | void, staleSessionId?: string | null, coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, - attemptGeneration = session.transportStreamGeneration + attemptGeneration = session.transportStreamGeneration, + launchContext: TerminalLaunchContext = captureTerminalLaunchContext( + sessionBag, + coldRestoreStartup ?? sessionBag.paneStartup + ) ): Promise => { if (session.disposed) { return false @@ -96,11 +106,21 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.remotePtyIncarnationId = null } + if (connectResult) { + applyTerminalLaunchResult(sessionBag, connectResult, launchContext, 'creator') + } if (connectResult?.exitedBeforeAttach) { // Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach. return true } + // Why: main started a plain shell instead, so nothing of the plan may be registered over it. + const startup = + connectResult?.agentResumeRefused || + connectResult?.agentResumeUnavailable || + connectResult?.isReattach + ? null + : coldRestoreStartup const ptyId = connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId()) const hasExplicitPtyId = Boolean(connectResult?.id || typeof result === 'string') @@ -126,14 +146,6 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi }) return false } - session.registerEffectiveLaunchConfig(connectResult?.launchConfig, { - ...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}), - ...(connectResult?.launchAgent - ? { launchAgent: connectResult.launchAgent } - : coldRestoreStartup - ? { launchAgent: coldRestoreStartup.agent } - : {}) - }) if (connectResult?.sessionExpired) { if (staleSessionId) { session.clearExitedPanePtyLayoutBinding(staleSessionId) @@ -144,7 +156,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) } // Why: a host can invalidate the PTY while the tab stays mounted; replace it in place, not with a stale overlay. - session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + session.startFreshColdRestoreAgentResume(startup, { forceBlankRestoredViewport: true }) return false @@ -172,8 +184,11 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi !(mainStarts && session.mainStartAttach) && hasEmptyReattachRetireEvidence( ptyId, - // Main's attach carries no resume; the evidence reads the pane's record all the same. - mainStarts ? session.buildColdRestoreAgentResumeStartup() : coldRestoreStartup + connectResult.agentResumeRefused + ? null + : mainStarts + ? session.buildColdRestoreAgentResumeStartup() + : coldRestoreStartup ) ) { session.transport.disconnect() @@ -279,7 +294,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi prefetchedParkModelSnapshot, revealFollowsTerminalPark, shouldApplyStructuralPayload, - coldRestoreStartup, + coldRestoreStartup: startup, reattachPayloadApplied: !shouldApplyStructuralPayload, skippedAltFrameCaptureCols: null } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts index c6f2350f748..1f1958a7e27 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts @@ -4,7 +4,12 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' /** Success must settle after no-ID/expiry rejection; otherwise healed attempts stay pending for 31s or failed attaches falsely reopen recovery budgets. */ const mocks = vi.hoisted(() => ({ - state: { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + state: { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } })) vi.mock('@/store', () => ({ @@ -64,7 +69,12 @@ function setPaneTransports(session: ConnectPanePtySession, transports: Map { vi.clearAllMocks() - mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + mocks.state = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } }) describe('handleReattachResult recovery settle', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts new file mode 100644 index 00000000000..6291ea78499 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts @@ -0,0 +1,396 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' +import type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata +} from '@/store/slices/agent-status-contract' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindSettlePaneSerializer } from './pane-serializer-settle' +import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' +import { installSleepingRecordAccess } from './sleeping-record-access' +import { createTestStore, makeTab } from '@/store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '@/store/slices/store-session-test-harness' +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' + +const fixture = vi.hoisted(() => ({ + state: {}, + setState: vi.fn(), + paste: vi.fn(), + parsed: vi.fn(async () => {}) +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => fixture.state, setState: fixture.setState } +})) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + waitForTerminalOutputParsed: fixture.parsed +})) +vi.mock('../terminal-startup-command-paste', () => ({ + executeTerminalStartupCommandPaste: fixture.paste +})) +vi.mock('../terminal-paste-runtime', () => ({ resolveTerminalPasteRuntime: () => ({}) })) +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +vi.mock('./command-inferred-pane-agent', () => ({ installCommandInferredPaneAgent: vi.fn() })) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + fixture.setState.mockReset() + vi.unstubAllGlobals() +}) + +describe('a refused saved startup and its delayed paste', () => { + it.each(['original', 'same-C', 'different-D', 'unverifiable'] as const)( + 'cancels only its captured source and keeps newer work (%s)', + async (kind) => { + vi.useFakeTimers() + const record = { + paneKey: 'tab:leaf', + tabId: 'tab', + worktreeId: 'wt', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + connectionId: null, + state: 'done' as const, + prompt: '', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep' as const + } + const source = { cancelled: false, record } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + resumeProviderSession: record.providerSession, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const records = kind === 'unverifiable' ? {} : { [record.paneKey]: record } + const clear = vi.fn() + const remember = vi.fn() + fixture.state = { + sleepingAgentSessionsByPaneKey: records, + automaticAgentResumeClaimsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + pendingStartupByTabId: {}, + tabsByWorktree: {}, + registerAgentLaunchConfig: vi.fn(), + clearAgentLaunchConfig: vi.fn(), + consumeTabStartupCommand: vi.fn(), + clearSleepingAgentSession: clear, + rememberSleepingAgentSession: remember + } + const sendInput = vi.fn(() => true) + const transport = { getPtyId: () => 'shell', sendInput } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installers' accessed session members are supplied here. + const session = { + pane: { id: 1, leafId: 'leaf', terminal: {} }, + manager: {}, + transport, + deps: { + worktreeId: 'wt', + tabId: 'tab', + paneTransportsRef: { current: new Map([[1, transport]]) } + }, + cacheKey: record.paneKey, + paneStartup: startup, + startupInjectTimer: null, + disposed: false, + transportStreamGeneration: 1, + shouldDeliverStartupViaTerminalPaste: true, + releaseUnattemptedStartupDraftPasteDelivery: vi.fn() + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + bindSettlePaneSerializer(session) + bindDeferredColdRestoreAndSnapshot(session) + let finishPaste: (() => void) | undefined + fixture.paste.mockImplementation( + () => + new Promise((resolve) => { + finishPaste = () => resolve({ status: 'pasted' }) + }) + ) + session.schedulePendingStartupCommandDelivery() + await vi.advanceTimersByTimeAsync(50) + expect(fixture.paste).toHaveBeenCalledOnce() + + const newer = { command: kind === 'different-D' ? 'codex resume D' : 'codex resume C' } + session.pendingStartupCommand = newer + if (kind === 'same-C' || kind === 'different-D') { + Object.assign(records, { + [record.paneKey]: { + ...record, + providerSession: { ...record.providerSession, id: kind === 'same-C' ? 'C' : 'D' }, + updatedAt: 20 + } + }) + } + session.cancelSavedResumeStartup(startup, { + id: 'shell', + agentResumeRefused: kind === 'unverifiable' ? 'unverifiable' : 'chat-home' + }) + expect(source.cancelled).toBe(true) + expect(session.pendingStartupCommand).toBe(newer) + finishPaste?.() + await vi.advanceTimersByTimeAsync(0) + expect(sendInput).not.toHaveBeenCalled() + expect(session.pendingStartupCommand).toBe(newer) + if (kind === 'unverifiable') { + expect(remember).toHaveBeenCalledWith(record) + } else if (kind === 'original') { + expect(clear).toHaveBeenCalledWith(record.paneKey) + } else { + expect(clear).not.toHaveBeenCalled() + } + } + ) +}) + +it.each(['refused', 'same-C', 'different-D'] as const)( + 'clears the real generated cold/wake registration and conditional tab identity only while owned (%s)', + (kind) => { + const cacheKey = 'tab:leaf' + const source = { cancelled: false } + const startup: ColdRestoreAgentResumeStartup = { + agent: 'codex', + command: 'codex resume C', + launchToken: 'saved-token', + launchConfig: { agentCommand: 'codex', agentArgs: 'resume C', agentEnv: {} }, + resumeProviderSession: { key: 'session_id', id: 'C' }, + resumeOrigin: 'saved', + resumeSource: source, + useLiveEntry: false, + hasSleepingRecord: false, + sleepingRecordEntry: null + } + const registry: Record = {} + const pending: Record = {} + const tab = { id: 'tab', title: 'Codex', launchAgent: 'codex', generation: 1 } + const clearTabLaunchAgent = vi.fn() + const registerAgentLaunchConfig = ( + paneKey: string, + launchConfig: AgentLaunchConfigRegistryEntry['launchConfig'], + identity: AgentLaunchConfigRegistrationMetadata + ) => { + registry[paneKey] = { launchConfig, identity, registeredAt: 1 } + } + fixture.state = { + agentLaunchConfigByPaneKey: registry, + pendingStartupByTabId: pending, + automaticAgentResumeClaimsByTabId: {}, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { wt: [tab] }, + registerAgentLaunchConfig, + clearAgentLaunchConfig: (key: string) => { + delete registry[key] + }, + consumeTabStartupCommand: vi.fn(), + clearTabLaunchAgent + } + const updateTabTitle = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real installers/cancellation access only the supplied session members in this contract. + const session = { + cacheKey, + paneStartup: null, + pane: { id: 1, leafId: 'leaf' }, + manager: { getActivePane: () => ({ id: 1 }) }, + deps: { tabId: 'tab', worktreeId: 'wt', setRuntimePaneTitle: vi.fn(), updateTabTitle }, + tabGeneration: 1, + startupInjectTimer: null, + transport: {}, + shouldDeliverStartupViaTerminalPaste: false + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + bindDeferredColdRestoreAndSnapshot(session) + expect(session.applyColdRestoreAgentResumeStartup(startup)).toBe(true) + const saved = registry[cacheKey] + expect(saved?.identity.launchToken).toBe('saved-token') + if (kind !== 'refused') { + const command = kind === 'same-C' ? 'resume C' : 'resume D' + registerAgentLaunchConfig( + cacheKey, + { ...startup.launchConfig, agentArgs: command }, + { + agentType: 'codex', + launchToken: 'explicit-token' + } + ) + pending.tab = { command } + } + const current = registry[cacheKey] + const newer = pending.tab + session.cancelSavedResumeStartup(startup, { id: 'shell', agentResumeRefused: 'chat-home' }) + session.registerEffectiveLaunchConfig(undefined, {}) + expect(source.cancelled).toBe(true) + expect(session.paneStartup).toBeNull() + if (kind === 'refused') { + expect(registry[cacheKey]).toBeUndefined() + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab') + expect(updateTabTitle).toHaveBeenCalledWith('tab', 'Terminal') + } else { + expect(registry[cacheKey]).toBe(current) + expect(pending.tab).toBe(newer) + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + expect(updateTabTitle).not.toHaveBeenCalled() + } + } +) + +it.each([10, 100])('counts real saved-startup cancellation map work at %i entries', (size) => { + vi.stubGlobal('window', undefined) + createStoreSessionMockApi() + for (const newer of [true, false]) { + const store = createTestStore() + fixture.state = store.getState() + fixture.setState.mockImplementation(store.setState) + let notifications = 0 + const unsubscribe = store.subscribe((state) => { + fixture.state = state + notifications += 1 + }) + const source = { cancelled: false } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + launchToken: 'saved-token', + launchConfig: { agentCommand: 'codex', agentArgs: 'resume C', agentEnv: {} }, + resumeProviderSession: { key: 'session_id' as const, id: 'C' }, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const claim = { + worktreeId: 'wt', + launchAgent: 'codex' as const, + providerSession: startup.resumeProviderSession + } + const status: AgentStatusEntry = { + paneKey: 'tab:leaf', + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [] + } + store.setState({ automaticAgentResumeClaimsByTabId: { tab: claim } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the actual installers/result policy access only these supplied session members. + const session = { + cacheKey: 'tab:leaf', + pane: { id: 1, leafId: 'leaf' }, + paneStartup: startup, + pendingStartupCommand: startup, + startupInjectTimer: null, + deps: { tabId: 'tab', worktreeId: 'wt' }, + showSessionRestoredBanner: vi.fn() + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + const registration = store.getState().agentLaunchConfigByPaneKey['tab:leaf']! + session.savedStartupStatus = { source, entry: status } + let enumerated = 0 + let copied = 0 + const counted = (map: T): T => + new Proxy(map, { + ownKeys(target) { + const keys = Reflect.ownKeys(target) + enumerated += keys.length + return keys + }, + getOwnPropertyDescriptor(target, key) { + const descriptor = Object.getOwnPropertyDescriptor(target, key) + if (descriptor?.enumerable) { + copied += 1 + } + return descriptor + } + }) + const population = (target: string, value: T) => + Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + index === size - 1 ? target : `other-${index}`, + value + ]) + ) + let tabVisits = 0 + const tabs = Array.from({ length: size }, (_, index) => + makeTab({ + id: index === size - 1 ? 'tab' : `other-${index}`, + worktreeId: 'wt' + }) + ) + // Keep the real tab objects; count the actual find predicate's identity reads. + tabs.forEach((tab, index) => + Object.defineProperty(tab, 'id', { + get: () => { + tabVisits += 1 + return index === size - 1 ? 'tab' : `other-${index}` + } + }) + ) + store.setState({ + pendingStartupByTabId: counted(population('tab', startup)), + automaticAgentResumeClaimsByTabId: counted(population('tab', claim)), + agentLaunchConfigByPaneKey: counted(population('tab:leaf', registration)), + agentStatusByPaneKey: counted(population('tab:leaf', status)), + tabsByWorktree: { wt: tabs } + }) + const context = captureTerminalLaunchContext(session, startup) + const successor = { ...startup, resumeSource: { cancelled: false } } + const successorRegistration = { ...registration, registeredAt: 2 } + if (newer) { + session.paneStartup = successor + session.pendingStartupCommand = successor + store.setState({ + pendingStartupByTabId: counted(population('tab', successor)), + automaticAgentResumeClaimsByTabId: counted(population('tab', { ...claim })), + agentLaunchConfigByPaneKey: counted(population('tab:leaf', successorRegistration)), + agentStatusByPaneKey: counted(population('tab:leaf', { ...status, updatedAt: 2 })) + }) + } + enumerated = 0 + copied = 0 + notifications = 0 + tabVisits = 0 + const result = { + agentResumeRefused: 'chat-home' as const, + savedResumeDecision: { + agent: 'codex', + providerSession: startup.resumeProviderSession, + verdict: 'chat-home' as const + } + } + try { + expect(applyTerminalLaunchResult(session, result, context, 'creator')).toBe(true) + expect(source.cancelled).toBe(true) + // Four independent whole-map spreads, once each; this is work per result, not a latency bound. + expect([enumerated, copied, notifications, tabVisits]).toEqual( + newer ? [0, 0, 0, 0] : [4 * size, 4 * size, 4, size] + ) + const current = store.getState() + if (newer) { + expect(current.pendingStartupByTabId.tab).toBe(successor) + expect(current.agentLaunchConfigByPaneKey['tab:leaf']).toBe(successorRegistration) + expect(current.automaticAgentResumeClaimsByTabId.tab).not.toBe(claim) + expect(current.agentStatusByPaneKey['tab:leaf']?.updatedAt).toBe(2) + expect(session.paneStartup).toBe(successor) + expect(successor.resumeSource.cancelled).toBe(false) + } else { + expect(current.pendingStartupByTabId.tab).toBeUndefined() + expect(current.agentLaunchConfigByPaneKey['tab:leaf']).toBeUndefined() + expect(current.automaticAgentResumeClaimsByTabId.tab).toBeUndefined() + expect(current.agentStatusByPaneKey['tab:leaf']).toBeUndefined() + expect(current.pendingStartupByTabId['other-0']).toBe(startup) + expect(session.pendingStartupCommand).toBeNull() + } + enumerated = 0 + copied = 0 + notifications = 0 + tabVisits = 0 + applyTerminalLaunchResult(session, result, context, 'creator') + expect([enumerated, copied, notifications, tabVisits]).toEqual([0, 0, 0, 0]) + } finally { + unsubscribe() + } + } +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts new file mode 100644 index 00000000000..f995fadd0f2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts @@ -0,0 +1,108 @@ +import { forgetRefusedPaneResume } from '@/lib/forget-refused-pane-resume' +import { useAppStore } from '@/store' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { PtyPaneStartup } from '../pty-connection-types' +import type { PtyConnectResult } from '../pty-transport-types' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { clearStaleAgentTabTitleOnConfirmedShell } from './confirmed-shell-agent-title' + +export function installSavedResumeStartupCancellation(session: ConnectPanePtySession): void { + session.cancelSavedResumeStartup = ( + startup: ColdRestoreAgentResumeStartup | PtyPaneStartup | null | undefined, + result: PtyConnectResult + ): void => { + const source = startup?.resumeSource + if (!startup || !source) { + return + } + source.cancelled = true + const state = useAppStore.getState() + if (session.pendingStartupCommand?.resumeSource === source) { + session.pendingStartupCommand = null + if (session.startupInjectTimer !== null) { + clearTimeout(session.startupInjectTimer) + } + session.startupInjectTimer = null + } + if (session.deferredSpawn?.startupOverride?.resumeSource === source) { + session.deferredSpawn = null + } + const ownsPaneStartup = session.paneStartup?.resumeSource === source + if (ownsPaneStartup) { + session.paneStartup = null + session.startupDraftPrompt = null + } + state.consumeTabStartupCommand(session.deps.tabId, startup) + const registered = session.savedStartupLaunchRegistration + const ownsRegistration = + registered?.source === source && + registered.entry !== undefined && + registered.entry === state.agentLaunchConfigByPaneKey[session.cacheKey] + session.clearRegisteredStartupLaunchConfig(startup) + const claim = state.automaticAgentResumeClaimsByTabId[session.deps.tabId] + if (ownsPaneStartup && claim && claim === session.savedStartupAutomaticClaim) { + useAppStore.setState((current) => { + if (current.automaticAgentResumeClaimsByTabId[session.deps.tabId] !== claim) { + return current + } + const next = { ...current.automaticAgentResumeClaimsByTabId } + delete next[session.deps.tabId] + return { automaticAgentResumeClaimsByTabId: next } + }) + } + if (ownsPaneStartup || ownsRegistration) { + const pending = state.pendingStartupByTabId[session.deps.tabId] + const installed = useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + const replaced = pending && pending.resumeSource !== source + if (!replaced && !installed) { + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + ) + if ( + tab?.launchAgent === ('agent' in startup ? startup.agent : startup.launchAgent) && + (tab?.generation ?? 0) === session.tabGeneration + ) { + state.clearTabLaunchAgent(session.deps.tabId) + clearStaleAgentTabTitleOnConfirmedShell(session) + } + } + } + if (ownsPaneStartup) { + const seeded = session.savedStartupStatus + if (seeded?.source === source) { + useAppStore.setState((current) => { + if (current.agentStatusByPaneKey[session.cacheKey] !== seeded.entry) { + return current + } + const next = { ...current.agentStatusByPaneKey } + delete next[session.cacheKey] + return { agentStatusByPaneKey: next } + }) + } + session.cleanupStartupDraftPasteTimers?.() + } + if ( + result.agentResumeRefused !== 'unverifiable' && + source.expectedStatus && + startup.resumeProviderSession + ) { + forgetRefusedPaneResume(state, source.expectedStatus.paneKey, { + agent: 'agent' in startup ? startup.agent : (startup.launchAgent ?? ''), + providerSession: startup.resumeProviderSession, + expectedStatus: source.expectedStatus + }) + } + const captured = source.record + if (!captured) { + return + } + const current = state.sleepingAgentSessionsByPaneKey[captured.paneKey] + if (result.agentResumeRefused === 'unverifiable') { + if (!current) { + state.rememberSleepingAgentSession(captured) + } + } else if (current === captured) { + state.clearSleepingAgentSession(captured.paneKey) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index af819c8bda1..d94232a2901 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -1,3 +1,5 @@ +import { installSavedResumeStartupCancellation } from './saved-resume-startup-cancellation' +import { launchConfigsEqual } from '../../../../../shared/agent-resume-record-equivalence' import { useAppStore } from '@/store' import type { PtyConnectResult } from '../pty-transport-types' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -55,6 +57,9 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi } } } + session.savedStartupAutomaticClaim = session.paneStartup?.resumeSource + ? useAppStore.getState().automaticAgentResumeClaimsByTabId[session.deps.tabId] + : undefined session.launchToken = session.paneStartup?.launchConfig ? (session.paneStartup.launchToken ?? createBrowserUuid()) : undefined @@ -114,6 +119,12 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi tabId: session.deps.tabId, leafId: session.pane.leafId }) + if (session.paneStartup.resumeSource) { + session.savedStartupLaunchRegistration = { + source: session.paneStartup.resumeSource, + entry: useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + } + } } else if (session.paneStartup) { useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) } @@ -154,9 +165,24 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi leafId: session.pane.leafId }) } - session.clearRegisteredStartupLaunchConfig = (): void => { - useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) + session.clearRegisteredStartupLaunchConfig = (expected = session.paneStartup): void => { + const state = useAppStore.getState() + const installed = state.agentLaunchConfigByPaneKey[session.cacheKey] + const captured = session.savedStartupLaunchRegistration + const ownsRegistration = expected?.resumeSource + ? captured?.source === expected.resumeSource && captured.entry === installed + : true + if ( + ownsRegistration && + installed && + expected?.launchConfig && + installed.identity.launchToken === (expected.launchToken ?? session.launchToken) && + launchConfigsEqual(installed.launchConfig, expected.launchConfig) + ) { + state.clearAgentLaunchConfig(session.cacheKey) + } } + installSavedResumeStartupCancellation(session) session.neutralTerminalTitle = (): string => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts index df90afb9106..0f3232297d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts @@ -149,17 +149,26 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void session.pendingSpawnDeferral = null session.deferredSpawn = null + /** Keeps the resume handle of a restore the host did not run, so a later restore tries again. */ + session.keepUnrunResumeHandle = ( + startupOverride: PendingStartupCommand | null | undefined + ): void => { + const captured = + startupOverride?.resumeSource?.record ?? session.paneStartup?.resumeSource?.record + const record = captured ?? deferredResumeRecord(session, startupOverride) + if (record && !useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]) { + // Why left after a later resume: the next quit capture replaces it, and a pane with a live PTY owns it. + useAppStore.getState().rememberSleepingAgentSession(record) + } + } + session.enterSpawnDeferredSleep = ( reason: SpawnDeferralReason, startupOverride: PendingStartupCommand | null | undefined, options: FreshSpawnOptions, visibleAtSpawn: boolean ): void => { - const record = deferredResumeRecord(session, startupOverride) - if (record) { - // Why left after a later resume: the next quit capture replaces it, and a pane with a live PTY owns it. - useAppStore.getState().rememberSleepingAgentSession(record) - } + session.keepUnrunResumeHandle(startupOverride) session.deferredSpawn = { reason, startupOverride, options } const deferral = { reason, resume: () => void session.resumeDeferredSpawn() } setPaneSpawnDeferral(session.cacheKey, deferral) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts new file mode 100644 index 00000000000..3a62b60f9ac --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts @@ -0,0 +1,95 @@ +import { + agentProviderSessionsEqual, + type TerminalLaunchResult +} from '../../../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { ReattachPayloadSession } from './reattach-payload-session' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { PtyPaneStartup } from '../pty-connection-types' + +type SavedStartup = ColdRestoreAgentResumeStartup | PtyPaneStartup + +export function captureTerminalLaunchContext( + session: ConnectPanePtySession | ReattachPayloadSession, + startup: SavedStartup +) { + const state = useAppStore.getState() + return { + startup, + registration: state.agentLaunchConfigByPaneKey[session.cacheKey], + status: state.agentStatusByPaneKey[session.cacheKey], + paneStartup: session.paneStartup + } +} + +export type TerminalLaunchContext = ReturnType + +export function applyTerminalLaunchResult( + session: ConnectPanePtySession | ReattachPayloadSession, + result: TerminalLaunchResult & { isReattach?: boolean }, + context: TerminalLaunchContext, + kind: 'creator' | 'observer' | 'candidate' +): boolean { + const startup = context.startup + const decision = result.savedResumeDecision + const agent = startup && ('agent' in startup ? startup.agent : startup.launchAgent) + const matches = Boolean( + startup && + decision && + decision.agent === agent && + agentProviderSessionsEqual( + decision.agent, + decision.providerSession, + startup.resumeProviderSession + ) + ) + const candidateRejected = kind === 'candidate' && (!matches || decision?.verdict !== 'admitted') + const rejected = Boolean( + result.agentResumeRefused || result.agentResumeUnavailable || candidateRejected + ) + const state = useAppStore.getState() + const currentStartup = session.paneStartup + const currentRegistration = state.agentLaunchConfigByPaneKey[session.cacheKey] + const superseded = + (kind === 'observer' && startup && decision && !matches) || + (currentStartup && + currentStartup !== context.paneStartup && + currentStartup.resumeSource !== startup?.resumeSource) || + (currentRegistration && currentRegistration !== context.registration) || + (state.agentStatusByPaneKey[session.cacheKey] && + state.agentStatusByPaneKey[session.cacheKey] !== context.status) + try { + if ( + rejected && + (!result.isReattach || result.agentResumeRefused) && + (matches || kind === 'candidate' || (kind === 'creator' && !result.isReattach)) + ) { + session.cancelSavedResumeStartup( + startup, + candidateRejected ? { ...result, agentResumeRefused: 'unverifiable' } : result + ) + } + } catch (error) { + console.warn('Terminal saved startup cancellation failed', error) + } + try { + if (!superseded && kind !== 'candidate') { + session.registerEffectiveLaunchConfig(result.launchConfig, { + launchToken: result.launchToken, + launchAgent: + result.launchAgent ?? + (!rejected && !result.isReattach && startup && 'agent' in startup + ? startup.agent + : undefined) + }) + } + } catch (error) { + // A bound shell remains usable even when exact-source bookkeeping fails. + console.warn('Terminal launch result bookkeeping failed', error) + } + if (rejected && !superseded) { + session.showSessionRestoredBanner('resume-unavailable') + } + return rejected +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts index 750d79cd8d3..263fd5e1ef7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts @@ -65,9 +65,10 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { } session.applyInitialAgentStatus = (terminalTitle?: string): void => { - const initialStatus = session.paneStartup?.initialAgentStatus + const startup = session.paneStartup + const initialStatus = startup?.initialAgentStatus const routing = session.resolveCurrentAgentStatusRouting() - if (!initialStatus || !routing) { + if (!initialStatus || !routing || startup.resumeSource?.cancelled) { return } const statusPayload = { @@ -90,11 +91,17 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { launchConfig: session.paneStartup.launchConfig, ...(session.launchToken ? { launchToken: session.launchToken } : {}) }) - return + } else { + useAppStore + .getState() + .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing) + } + if (startup.resumeSource) { + session.savedStartupStatus = { + source: startup.resumeSource, + entry: useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + } } - useAppStore - .getState() - .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing) } session.canApplyCommandCodeOutputStatus = (): boolean => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts index 5ad7e57f996..eb45ac1c7ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts @@ -1,3 +1,9 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { TerminalAttachmentLaunchResult } from '../../../../../shared/terminal-attachment-launch-result' import { isPaneOwnerRefusal } from './pane-owner-recovery' import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission' import { createTerminalStartupTiming } from '../terminal-startup-timing' @@ -12,8 +18,10 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi const ownerErrors = new Set() session.captureTransportOutputCallbacks = ( onError: (message: string) => void, - startup: PtyPaneStartup + startup: PtyPaneStartup, + savedStartup: ColdRestoreAgentResumeStartup | PtyPaneStartup = session.paneStartup ) => { + const launchContext = captureTerminalLaunchContext(session, savedStartup) // New stream generations drop old replay fit continuations and release live-data waiters. session.pendingHiddenSnapshotFit?.cancel() session.pendingHiddenSnapshotFit = null @@ -38,7 +46,18 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi }) return { generation, + launchContext, callbacks: { + onLaunchResult: (result: TerminalAttachmentLaunchResult): void => { + if ( + isCurrent() && + (!session.remotePtyIncarnationId || + session.remotePtyIncarnationId === result.incarnationId) + ) { + session.remotePtyIncarnationId = result.incarnationId + applyTerminalLaunchResult(session, result, launchContext, 'observer') + } + }, onReattachDetermined: (): void => { if (isCurrent()) { session.beginReattachLiveDataDeferralIfUnowned(generation) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 529502d055f..3142f589247 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,3 +1,4 @@ +import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { RestoreSpawnIntent, @@ -5,6 +6,7 @@ import type { } from '../../../../shared/host-spawn-admission' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -80,7 +82,7 @@ export type LocalPtySessionMetadata = { shellOverride?: string } -export type PtyConnectResult = { +export type PtyConnectResult = TerminalLaunchResult & { id: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string @@ -112,11 +114,15 @@ export type PtyConnectResult = { startupCwdFallback?: { kind: 'worktree'; cwd: string } /** Main declined an unverifiable provider-session resume and launched fresh. */ agentResumeUnavailable?: true + /** Main refused the resume and started the plain shell: a native chat is the conversation's home + * or may be writing it, or no record could be checked (the saved session is kept to retry). */ + agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' /** Write daemon’s trailing partial escape after reset so racing live bytes can complete it (#7329). */ pendingEscapeTailAnsi?: string } type PtyCallbacks = { + onLaunchResult?: (result: TerminalAttachmentLaunchResult) => void /** Called before an adopted PTY can publish buffered/live bytes. */ onReattachDetermined?: () => void onConnect?: () => void @@ -178,6 +184,7 @@ export type PtyTransport = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -274,6 +281,7 @@ export type IpcPtyTransportOptions = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' agentPrompt?: string agentPromptDelivery?: AgentPromptDelivery agentArgsOverride?: string | null diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts index b512254b5ee..1cdd5cfc05c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts @@ -7,17 +7,23 @@ import { let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' -const { runtimeCall, refreshSessionTabsSnapshot, resetRemoteRuntimeTransport } = - createRemoteRuntimeTransportMocks({ - getCallbacks: () => subscriptionCallbacks, - setCallbacks: (callbacks) => { - subscriptionCallbacks = callbacks - }, - getResolvedPaneHandle: () => resolvedPaneHandle, - setResolvedPaneHandle: (handle) => { - resolvedPaneHandle = handle - } - }) +const { + runtimeCall, + refreshSessionTabsSnapshot, + resetRemoteRuntimeTransport, + latestSubscribePayload, + emitSnapshot, + inputFrameTexts +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) describe('createRemoteRuntimePtyTransport', () => { it.each([ @@ -498,4 +504,55 @@ describe('createRemoteRuntimePtyTransport', () => { expect.objectContaining({ method: 'terminal.createAgentSession' }) ) }) + it.each(['existing', 'fresh', 'legacy'] as const)( + 'uses subscription image for an existing intermediate-host owner and keeps fresh cold data (%s)', + async (kind) => { + const coldRestore = { scrollback: 'old checkpoint A', cwd: '/workspace' } + runtimeCall.mockImplementation(async (args: { method: string }) => ({ + ok: true, + result: + args.method === 'terminal.create' + ? { + terminal: { + handle: 'terminal-1', + ...(kind === 'existing' ? { isReattach: true, incarnationId: 'i1' } : {}), + ...(kind !== 'legacy' ? { coldRestore } : {}) + } + } + : {} + })) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const onReplayData = vi.fn() + const onConnect = vi.fn() + const result = await transport.connect({ url: '', callbacks: { onReplayData, onConnect } }) + try { + if (!result || typeof result === 'string') { + throw new Error('Expected a terminal connection result') + } + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + expect(result?.coldRestore).toEqual(kind === 'fresh' ? coldRestore : undefined) + expect(result?.isReattach).toBe(kind === 'existing' ? true : undefined) + expect(result?.replay).toBe('') + const { streamId } = latestSubscribePayload() + emitSnapshot(streamId, 'actual owner image B') + expect(onReplayData.mock.calls).toEqual([ + ['actual owner image B', { carriesNormalBuffer: true }] + ]) + expect(onConnect).toHaveBeenCalled() + expect(inputFrameTexts()).toEqual([]) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + } finally { + transport.destroy?.() + } + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 0e2213064b4..043a0b945fa 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -1,3 +1,4 @@ +import { ExplicitEnsure } from '../../../../shared/rpc-contract/agent-session-params' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appStore = vi.hoisted(() => ({ getState: vi.fn(() => { @@ -126,6 +127,63 @@ describe('createRemoteRuntimePtyTransport', () => { return payload } + it('round-trips a strict saved ensure and its actual refused shell result', async () => { + const providerSession = { key: 'session_id' as const, id: 'C' } + const decision = { agent: 'codex' as const, providerSession, verdict: 'chat-home' as const } + runtimeCall.mockImplementation(async (args: { method?: string; params?: unknown }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent-session.host-authority.v1', + 'agent-session.saved-resume-origin.v1' + ] + } + } + } + expect(args.method).toBe('terminal.ensureAgentSession') + expect(ExplicitEnsure.parse(args.params)).toMatchObject({ + resumeOrigin: 'saved', + providerSession + }) + return { + ok: true, + result: { + disposition: 'created', + terminal: { + handle: 'term-remote', + worktreeId: 'repo1::/remote/wt', + title: null, + savedResumeDecision: decision, + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true + } + } + } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + launchAgent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: providerSession, + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' + }) + const result = await transport.connect({ url: '', callbacks: {} }) + expect(result).toMatchObject({ + id: 'remote:env-1@@term-remote', + savedResumeDecision: decision, + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true + }) + expect(result).not.toHaveProperty('launchAgent') + transport.disconnect() + }) + it('creates and subscribes to a terminal on the active remote runtime', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onReplayData = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts index 9ab9a4220f6..dd77e1afd8d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts @@ -21,7 +21,7 @@ function okResponse(result: unknown): unknown { } describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { - const runtimeCall = vi.fn() + const runtimeCall = vi.fn<(args: RpcCall) => Promise>() let capabilities: string[] = [] let ensureResponse: unknown = null @@ -65,12 +65,16 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { vi.unstubAllGlobals() }) - async function connectResume(callbacks: Record = {}): Promise { + async function connectResume( + callbacks: Record = {}, + saved = false + ): Promise { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'repo1::/remote/wt', launchAgent: 'claude', resumeProviderSession: RESUME, + ...(saved ? { resumeOrigin: 'saved' as const } : {}), tabId: 'tab-1', leafId: LEAF_ID }) @@ -103,6 +107,46 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { expect(ensureParams()).not.toHaveProperty('spawnIntent') }) + it.each([false, true])( + 'keeps saved-origin negotiation stable through an unknown outcome (capability %s)', + async (modern) => { + if (modern) { + capabilities.push('agent-session.saved-resume-origin.v1') + } + let attempts = 0 + runtimeCall.mockImplementation(async (args: RpcCall) => { + if (args.method === 'status.get') { + return okResponse({ + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities + }) + } + if (args.method === 'terminal.ensureAgentSession' && attempts++ === 0) { + capabilities = [...capabilities, 'agent-session.saved-resume-origin.v1'] + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + return ensureResponse + }) + const onSpawnDeferred = vi.fn() + await connectResume({ onSpawnDeferred }, true) + const requests = runtimeCall.mock.calls + .map(([call]) => call) + .filter((call) => call.method === 'terminal.ensureAgentSession') + expect(requests).toHaveLength(2) + expect(requests[1].params).toEqual(requests[0].params) + expect(requests[0].params).toMatchObject({ kind: 'explicit', providerSession: RESUME }) + if (modern) { + expect(requests[0].params).toHaveProperty('resumeOrigin', 'saved') + } else { + expect(requests[0].params).not.toHaveProperty('resumeOrigin') + } + expect(onSpawnDeferred).toHaveBeenCalledWith('paused') + } + ) + it('reports a deferral to the pane instead of surfacing an error', async () => { capabilities.push('terminal.spawn-intent.v1') const onError = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 0a1933a946e..b2133318648 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,5 +1,11 @@ +import { parseTerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' +import { + terminalLaunchFacts, + agentProviderSessionForEnsure +} from '../../../../shared/agent-session-resume' +import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' -import { agentProviderSessionForEnsure } from '../../../../shared/agent-session-resume' import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent' import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' import { createRemoteRuntimeDisconnectedInputGrace } from './remote-runtime-disconnected-input-grace' @@ -36,7 +42,10 @@ import type { RuntimeTerminalSend, RuntimeTerminalShow } from '../../../../shared/runtime-types' -import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { agentResumeHostAuthorityCapability } from '../../runtime/agent-resume-host-authority-capability' import { isTerminalInputTooLargeWithDeferredMeasurement, @@ -212,6 +221,7 @@ export function createRemoteRuntimePtyTransport( envToDelete, launchConfig, resumeProviderSession, + resumeOrigin, launchToken, launchAgent, terminalColorQueryReplies, @@ -568,6 +578,8 @@ export function createRemoteRuntimePtyTransport( } // Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records. const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}` + let replayLaunch: ((epoch: number) => Promise) | undefined + let savedOriginOptions: Promise<{ resumeOrigin?: 'saved' }> | undefined const terminalCreateMutationId = createBrowserUuid() // Replay one stable host operation after lost responses so reconnect cannot launch a second agent. const agentCreateOperation = createAgentSessionCreateOperation() @@ -1652,6 +1664,11 @@ export function createRemoteRuntimePtyTransport( if (!expiredHandle || !tabId || !leafId || !worktreeId || recoveringPaneHandle) { return } + const recoverySubscriptionGeneration = subscriptionGeneration + const recoveryLifecycleEpoch = lifecycleEpoch + const recoveryCallbacks = storedCallbacks + const ownsRecoveryLifecycle = (): boolean => + recoveryLifecycleEpoch === lifecycleEpoch && recoveryCallbacks === storedCallbacks recoveringPaneHandle = expiredHandle connected = false clearPendingViewportClaim() @@ -1663,7 +1680,7 @@ export function createRemoteRuntimePtyTransport( expectedTerminal: expiredHandle }) .then(async ({ terminal }) => { - if (destroyed || handle !== expiredHandle) { + if (destroyed || handle !== expiredHandle || !ownsRecoveryLifecycle()) { return } const previousIncarnationId = authoritativePtyIncarnationId @@ -1688,10 +1705,32 @@ export function createRemoteRuntimePtyTransport( onPtyRebind?.(remotePtyId, replacedPtyId) } } - await subscribeToHandle() + if (!ownsRecoveryLifecycle()) { + return + } + const launchResult = parseTerminalAttachmentLaunchResult({ + ...terminalLaunchFacts(terminal), + ptyId: terminal.ptyId, + incarnationId: terminal.incarnationId + }) + if ( + launchResult && + ownsRecoveryLifecycle() && + recoverySubscriptionGeneration === subscriptionGeneration && + currentPaneRevision() === getRuntimeEnvironmentRevision(currentRuntimeEnvironmentId) + ) { + try { + recoveryCallbacks.onLaunchResult?.(launchResult) + } catch (error) { + console.warn('Terminal launch result bookkeeping failed', error) + } + } + if (ownsRecoveryLifecycle()) { + await subscribeToHandle() + } }) .catch((error) => { - if (!destroyed && handle === expiredHandle) { + if (!destroyed && handle === expiredHandle && ownsRecoveryLifecycle()) { surfaceErrorMessage(runtimeTerminalErrorMessage(error)) } }) @@ -2837,6 +2876,10 @@ export function createRemoteRuntimePtyTransport( } const subscribedHandle = handle const subscribedPtyId = remotePtyId + const subscribedLifecycleEpoch = lifecycleEpoch + const subscribedCallbacks = storedCallbacks + const ownsSubscriptionLifecycle = (): boolean => + subscribedLifecycleEpoch === lifecycleEpoch && subscribedCallbacks === storedCallbacks const generation = ++subscriptionGeneration const recoveryEpoch = expectedRecoveryEpoch ?? (recovery.isActive ? recovery.currentEpoch : undefined) @@ -2851,6 +2894,7 @@ export function createRemoteRuntimePtyTransport( const isCurrentSubscription = (): boolean => !transportClosed && generation === subscriptionGeneration && + ownsSubscriptionLifecycle() && (recoveryEpoch === undefined || recovery.isCurrent(recoveryEpoch) || (subscriptionAttached && recovery.ownsEpoch(recoveryEpoch))) && @@ -2925,8 +2969,23 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onPausedOutputDropped?.() } }, - onSubscribeAcknowledged: () => { + onSubscribeAcknowledged: (result) => { if (isCurrentSubscription()) { + if ( + result && + (!authoritativePtyIncarnationId || + result.incarnationId === authoritativePtyIncarnationId) + ) { + authoritativePtyIncarnationId = result.incarnationId + try { + subscribedCallbacks.onLaunchResult?.(result) + } catch (error) { + console.warn('Terminal launch result bookkeeping failed', error) + } + } + if (!isCurrentSubscription()) { + return + } inputReadySubscription = generation if ( installedStream && @@ -3093,6 +3152,7 @@ export function createRemoteRuntimePtyTransport( }) if ( transportClosed || + !ownsSubscriptionLifecycle() || generation !== subscriptionGeneration || (recoveryEpoch !== undefined && !(subscriptionAttached @@ -3397,6 +3457,23 @@ export function createRemoteRuntimePtyTransport( if (restoreSpawnIntent && (destroyed || lifecycleEpoch !== connectLifecycleEpoch)) { return } + const originOptions = + (options.resumeOrigin ?? resumeOrigin) === 'saved' + ? await (savedOriginOptions ??= (async () => { + try { + const supported = await runtimeEnvironmentSupportsCapability( + createEnvironmentId, + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY + ) + return supported ? { resumeOrigin: 'saved' as const } : {} + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + return {} + } + })()) + : {} const legacyCreateParams = { worktree: toRuntimeTerminalWorktreeSelector(worktreeId), clientMutationId: terminalCreateMutationId, @@ -3410,6 +3487,7 @@ export function createRemoteRuntimePtyTransport( ...(resumeProviderSessionToSend !== undefined ? { resumeProviderSession: resumeProviderSessionToSend } : {}), + ...originOptions, ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), ...spawnIntentParams, @@ -3422,7 +3500,7 @@ export function createRemoteRuntimePtyTransport( presentation: 'background' as const, ...(activate === true ? { activate: true } : {}) } - const legacyCreate = () => + const legacyCreate = (epoch = connectLifecycleEpoch) => createWithUnknownOutcomeRecovery( 'terminal', (timeoutMs, reconcileExisting) => @@ -3436,9 +3514,9 @@ export function createRemoteRuntimePtyTransport( timeoutMs ), createEnvironmentId, - connectLifecycleEpoch + epoch ) - const hostAuthorityCreate = async () => { + const hostAuthorityCreate = async (epoch = connectLifecycleEpoch) => { const keyboardOptions = await agentKeyboardOptions(createEnvironmentId) return createWithUnknownOutcomeRecovery( 'agent-session', @@ -3450,6 +3528,7 @@ export function createRemoteRuntimePtyTransport( { kind: 'explicit', ...keyboardOptions, + ...originOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, providerSession: agentProviderSessionForEnsure(resumeProviderSessionToSend), @@ -3488,24 +3567,32 @@ export function createRemoteRuntimePtyTransport( timeoutMs ), createEnvironmentId, - connectLifecycleEpoch + epoch ) } const resumeHostAuthorityCapability = resumeProviderSessionToSend ? agentResumeHostAuthorityCapability(launchAgentToSend) : undefined - const created = launchAgentToSend - ? agentSessionRequiresHostAuthorityReplay - ? await hostAuthorityCreate() - : await runRemoteAgentSessionLaunch({ - environmentId: createEnvironmentId, - hostAuthority: hostAuthorityCreate, - ...(resumeHostAuthorityCapability - ? { hostAuthorityCapability: resumeHostAuthorityCapability } - : {}), - legacy: legacyCreate - }) - : await legacyCreate() + const created = replayLaunch + ? await replayLaunch(connectLifecycleEpoch) + : launchAgentToSend + ? agentSessionRequiresHostAuthorityReplay + ? await hostAuthorityCreate() + : await runRemoteAgentSessionLaunch({ + environmentId: createEnvironmentId, + hostAuthority: () => { + replayLaunch = hostAuthorityCreate + return hostAuthorityCreate() + }, + ...(resumeHostAuthorityCapability + ? { hostAuthorityCapability: resumeHostAuthorityCapability } + : {}), + legacy: () => { + replayLaunch = legacyCreate + return legacyCreate() + } + }) + : await (replayLaunch = legacyCreate)() if (!created) { if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false @@ -3573,6 +3660,10 @@ export function createRemoteRuntimePtyTransport( } return { + ...terminalLaunchFacts(createdTerminal), + ...(createdTerminal.isReattach !== true && createdTerminal.coldRestore + ? { coldRestore: createdTerminal.coldRestore } + : {}), id: remotePtyId, replay: '', ...(authoritativePtyIncarnationId ? { incarnationId: authoritativePtyIncarnationId } : {}), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts new file mode 100644 index 00000000000..1418390a598 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' +import type { ConnectPanePtySession } from './pty-connection/connect-pane-pty-session' +import type { AgentLaunchConfigRegistryEntry } from '@/store/slices/agent-status-contract' +import { bindCaptureTransportOutputCallbacks } from './pty-connection/transport-output-callbacks' +import { bindSettlePaneSerializer } from './pty-connection/pane-serializer-settle' +import { applyTerminalLaunchResult } from './pty-connection/terminal-launch-result-policy' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame +} from '../../../../shared/terminal-stream-protocol' +import { installSavedResumeStartupCancellation } from './pty-connection/saved-resume-startup-cancellation' +import { + createRemoteRuntimeTransportMocks, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' + +const fixture = vi.hoisted(() => ({ state: {} })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => fixture.state } })) +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' +const { + runtimeCall, + subscriptionSendBinary, + latestSubscribePayload, + emitSnapshot, + inputFrameTexts, + resetRemoteRuntimeTransport +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) +beforeEach(resetRemoteRuntimeTransport) +afterEach(() => vi.clearAllMocks()) + +it.each([ + 'refused', + 'adopted', + 'adopted-D', + 'observer-only', + 'unavailable', + 'legacy', + 'ackless', + 'malformed', + 'bookkeeping-error', + 'new-C', + 'new-D', + 'stale-generation' +] as const)( + 'delivers the actual mirror %s result to the pane without replaying or clearing newer work', + async (kind) => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + const record = { + paneKey: 'web-terminal-host-tab-1:pane:1', + tabId: 'web-terminal-host-tab-1', + worktreeId: 'wt-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + connectionId: null, + state: 'done' as const, + prompt: '', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep' as const + } + const source = { cancelled: false, record } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + resumeProviderSession: record.providerSession, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const registry: Record = {} + const records = { [record.paneKey]: record } + const clearRecord = vi.fn((key: string) => { + delete records[key] + }) + fixture.state = { + agentLaunchConfigByPaneKey: registry, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: records, + automaticAgentResumeClaimsByTabId: {}, + pendingStartupByTabId: {}, + tabsByWorktree: {}, + consumeTabStartupCommand: vi.fn(() => { + if (kind === 'bookkeeping-error') { + throw new Error('bookkeeping failed') + } + }), + clearSleepingAgentSession: clearRecord + } + const note = vi.fn() + const register = vi.fn() + const callbacksReady = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real callback and cancellation installers use the supplied session members. + const session = { + transport, + pane: { id: 1, leafId: 'pane:1', container: { dataset: {} }, terminal: {} }, + deps: { + worktreeId: 'wt-1', + tabId: record.tabId, + isVisibleRef: { current: true }, + onShowSessionRestoredBanner: note, + paneTransportsRef: { current: new Map([[1, transport]]) } + }, + cacheKey: record.paneKey, + paneStartup: kind === 'observer-only' ? null : startup, + pendingStartupCommand: kind === 'observer-only' ? null : startup, + shouldDeliverStartupViaTerminalPaste: true, + startupInjectTimer: null, + transportStreamGeneration: 0, + disposed: false, + createProcessExitState: () => ({ detector: { observe: vi.fn() } }), + clearRegisteredStartupLaunchConfig: vi.fn(), + registerEffectiveLaunchConfig: register, + showSessionRestoredBanner: note, + reportRemoteRendererSerializerReady: callbacksReady, + syncHiddenRendererPtyDelivery: vi.fn(), + handleRemoteOutputPauseChanged: vi.fn(), + markHiddenOutputRestoreNeeded: vi.fn(), + beginReattachLiveDataDeferralIfUnowned: vi.fn(), + replayDataCallback: vi.fn(), + dataCallback: vi.fn() + } as unknown as ConnectPanePtySession + installSavedResumeStartupCancellation(session) + bindSettlePaneSerializer(session) + session.reportRemoteRendererSerializerReady = callbacksReady + bindCaptureTransportOutputCallbacks(session) + const output = session.captureTransportOutputCallbacks(vi.fn(), null) + const actualConfig = { agentCommand: 'codex', agentArgs: '--model actual', agentEnv: {} } + const launchResult: TerminalAttachmentLaunchResult = { + ptyId: 'physical-1', + incarnationId: 'incarnation-1', + ...(kind === 'adopted' + ? {} + : { + savedResumeDecision: { + agent: 'codex', + providerSession: + kind === 'adopted-D' ? { key: 'session_id', id: 'D' } : record.providerSession, + verdict: kind === 'unavailable' ? 'admitted' : 'chat-home' + }, + agentResumeUnavailable: true, + ...(kind === 'unavailable' ? {} : { agentResumeRefused: 'chat-home' }) + }), + ...(kind === 'adopted' || kind === 'adopted-D' || kind === 'unavailable' + ? { + launchAgent: 'codex', + launchToken: 'actual-token', + launchConfig: actualConfig + } + : {}) + } + subscriptionSendBinary.mockImplementation((bytes) => { + if (decodeTerminalStreamFrame(bytes)?.opcode !== TerminalStreamOpcode.Subscribe) { + return + } + if (kind === 'new-C' || kind === 'new-D') { + const providerSession = { + key: 'session_id' as const, + id: kind === 'new-D' ? 'D' : 'C' + } + const newer = { + ...startup, + resumeProviderSession: providerSession, + resumeSource: { cancelled: false } + } + session.paneStartup = newer + session.pendingStartupCommand = newer + registry[record.paneKey] = { launchConfig: actualConfig, identity: {}, registeredAt: 20 } + records[record.paneKey] = { ...record, providerSession, updatedAt: 20 } + } + if (kind === 'stale-generation') { + session.transportStreamGeneration += 1 + } + const { streamId, terminal } = latestSubscribePayload() + // Arrive synchronously while subscribeTerminal is still installing the stream. + if (kind === 'ackless') { + return + } + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId, + terminal, + ...(kind === 'legacy' + ? {} + : { + launchResult: + kind === 'malformed' + ? { ...launchResult, savedResumeDecision: { agent: 'codex' } } + : launchResult + }) + } + }) + }) + try { + transport.attach({ existingPtyId: 'remote:env-1@@terminal-1', callbacks: output.callbacks }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(callbacksReady).not.toHaveBeenCalled() + const { streamId } = latestSubscribePayload() + const resolvesBeforeSnapshot = runtimeCall.mock.calls.filter( + ([request]) => request.method === 'terminal.resolvePane' + ).length + transport.sendInput('ordinary-input', 'driving') + if (kind === 'ackless') { + expect(inputFrameTexts().join('')).not.toContain('ordinary-input') + } else { + await vi.waitFor(() => expect(inputFrameTexts().join('')).toContain('ordinary-input')) + } + if (kind === 'refused' || kind === 'bookkeeping-error' || kind === 'unavailable') { + expect(session.pendingStartupCommand).toBeNull() + } + emitSnapshot(streamId, 'shell$ ') + await vi.waitFor(() => expect(inputFrameTexts().join('')).toContain('ordinary-input')) + if (kind !== 'stale-generation') { + expect(callbacksReady).toHaveBeenCalledTimes(1) + } + const refused = + kind === 'refused' || kind === 'bookkeeping-error' || kind === 'new-C' || kind === 'new-D' + expect(source.cancelled).toBe(refused || kind === 'unavailable') + expect(note).toHaveBeenCalledTimes( + kind === 'refused' || + kind === 'bookkeeping-error' || + kind === 'observer-only' || + kind === 'unavailable' + ? 1 + : 0 + ) + expect(clearRecord).toHaveBeenCalledTimes( + kind === 'refused' || kind === 'unavailable' ? 1 : 0 + ) + if (kind === 'new-C' || kind === 'new-D') { + expect(session.paneStartup?.resumeSource?.cancelled).toBe(false) + expect(session.pendingStartupCommand).toBe(session.paneStartup) + expect(records[record.paneKey].updatedAt).toBe(20) + expect(register).not.toHaveBeenCalled() + } else if (kind === 'adopted' || kind === 'unavailable') { + expect(register).toHaveBeenCalledWith(actualConfig, { + launchAgent: 'codex', + launchToken: 'actual-token' + }) + } else if (kind === 'refused' || kind === 'observer-only') { + expect(session.pendingStartupCommand).toBeNull() + expect(session.paneStartup).toBeNull() + expect(register).toHaveBeenCalledWith(undefined, { + launchAgent: undefined, + launchToken: undefined + }) + } else if (kind !== 'bookkeeping-error') { + expect(register).not.toHaveBeenCalled() + } + if (kind === 'adopted-D') { + applyTerminalLaunchResult( + session, + { ...launchResult, isReattach: true }, + output.launchContext, + 'creator' + ) + expect(source.cancelled).toBe(false) + expect(session.pendingStartupCommand?.resumeSource).toBe(source) + expect(records[record.paneKey]).toBe(record) + expect(clearRecord).not.toHaveBeenCalled() + expect(register).toHaveBeenCalledWith(actualConfig, { + launchAgent: 'codex', + launchToken: 'actual-token' + }) + } + if (kind === 'refused' || kind === 'unavailable') { + // The returning creator repeats the acknowledgment; existing note idempotence survives. + applyTerminalLaunchResult(session, launchResult, output.launchContext, 'creator') + expect(note).toHaveBeenCalledTimes(1) + } + expect(inputFrameTexts().join('')).not.toContain('codex resume C') + expect( + runtimeCall.mock.calls.filter(([request]) => request.method === 'terminal.resolvePane') + ).toHaveLength(resolvesBeforeSnapshot) + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe( + false + ) + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.send')).toBe( + false + ) + } finally { + transport.disconnect() + } + } +) + +it.each(['subscription', 'recovery'] as const)( + 'rejects predecessor %s facts while a successor connect owns the callback bundle', + async (operation) => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + const predecessorResult = vi.fn() + const successorResult = vi.fn() + const launchResult: TerminalAttachmentLaunchResult = { + ptyId: 'physical-1', + incarnationId: 'incarnation-1', + agentResumeRefused: 'chat-home', + savedResumeDecision: { + agent: 'codex', + providerSession: { key: 'session_id', id: 'C' }, + verdict: 'chat-home' + } + } + let finishRecovery: (value: unknown) => void = () => {} + const recoveryResult = new Promise((resolve) => { + finishRecovery = resolve + }) + let finishActivation: (value: unknown) => void = () => {} + const activationResult = new Promise((resolve) => { + finishActivation = resolve + }) + try { + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + callbacks: { onLaunchResult: predecessorResult } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId, terminal } = latestSubscribePayload() + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation((request: { method: string }) => { + if (request.method === 'terminal.recoverPane') { + return recoveryResult + } + if (request.method === 'session.tabs.activate') { + return activationResult + } + return defaultCall?.(request) + }) + if (operation === 'recovery') { + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'error', streamId, message: 'SSH_SESSION_EXPIRED: relay changed' } + }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.recoverPane' }) + ) + ) + } + const successor = transport.connect({ + url: '', + callbacks: { onLaunchResult: successorResult } + }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.activate' }) + ) + ) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'subscribed', streamId, terminal, launchResult } + }) + finishRecovery({ + ok: true, + result: { terminal: { handle: terminal, ...launchResult } } + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(predecessorResult).not.toHaveBeenCalled() + expect(successorResult).not.toHaveBeenCalled() + transport.disconnect() + finishActivation({ ok: true, result: {} }) + await successor + } finally { + transport.disconnect() + finishRecovery({ ok: true, result: {} }) + finishActivation({ ok: true, result: {} }) + } + } +) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 16e50bce1b5..fe9d365c748 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -1,16 +1,48 @@ import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import type { AgentStatusClearIpcPayload, - AgentStatusIpcPayload + AgentStatusIpcPayload, + RefusedPaneResume } from '../../../../shared/agent-status-types' import { resolveLegacyWorkerTerminalRecoveryAction, rollbackLegacyWorkerTerminalSurfaceInStore } from '../legacy-worker-terminal-recovery-event' import { useAppStore } from '../../store' +import { forgetRefusedPaneResume, namesRefusedResume } from '@/lib/forget-refused-pane-resume' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import { resolvePaneKey } from './agent-status-routing' import type { PendingAgentStatusEvent } from './agent-status-bridge-types' +/** An already delivered event for the pane naming a refused conversation would restore it. */ +function dropEventsRestoringRefused( + paneKey: string, + refused: RefusedPaneResume, + queues: { + pendingAgentStatusEvents: PendingAgentStatusEvent[] + liveAgentStatusBurstQueue: AgentStatusIpcPayload[] + } +): void { + const restores = (event: AgentStatusIpcPayload): boolean => + !!refused.expectedStatus && + event.receivedAt <= refused.expectedStatus.receivedAt && + event.stateStartedAt === refused.expectedStatus.stateStartedAt && + resolveAgentPaneAuthorityKey(event.paneKey) === paneKey && + namesRefusedResume(refused, { ...event, agent: event.agentType }) + const pending = queues.pendingAgentStatusEvents + for (let index = pending.length - 1; index >= 0; index -= 1) { + if (restores(pending[index].data)) { + pending.splice(index, 1) + } + } + const burst = queues.liveAgentStatusBurstQueue + for (let index = burst.length - 1; index >= 0; index -= 1) { + if (restores(burst[index])) { + burst.splice(index, 1) + } + } +} + export function registerAgentStatusListeners(args: { unsubs: (() => void)[] enqueueLiveAgentStatus: (data: AgentStatusIpcPayload) => void @@ -72,6 +104,14 @@ export function registerAgentStatusListeners(args: { if (!('paneKey' in data) || typeof data.paneKey !== 'string') { return } + if (data.refusedResume) { + dropEventsRestoringRefused(data.paneKey, data.refusedResume, { + pendingAgentStatusEvents, + liveAgentStatusBurstQueue + }) + forgetRefusedPaneResume(useAppStore.getState(), data.paneKey, data.refusedResume) + return + } // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { drainQueuedLiveAgentStatusesForPane(data.paneKey) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts new file mode 100644 index 00000000000..e9cc5a5dd12 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts @@ -0,0 +1,155 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { + AgentStatusIpcPayload, + RefusedPaneResume +} from '../../../../shared/agent-status-types' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '@/store/slices/store-session-test-harness' +import { registerAgentStatusListeners } from './agent-status-listeners' + +const bridge = vi.hoisted(() => ({ getState: vi.fn(), setState: vi.fn() })) +vi.mock('@/store', () => ({ useAppStore: bridge })) + +afterEach(() => vi.unstubAllGlobals()) + +it.each([10, 100])( + 'counts refused-clear queue moves and real store notifications at %i entries', + (size) => { + vi.stubGlobal('window', undefined) + createStoreSessionMockApi() + const store = createTestStore() + bridge.getState.mockImplementation(store.getState) + bridge.setState.mockImplementation(store.setState) + const refused: RefusedPaneResume = { + agent: 'codex', + providerSession: { key: 'session_id', id: 'conversation' }, + expectedStatus: { paneKey: 'tab:leaf', receivedAt: 10, stateStartedAt: 1 }, + expectedSavedRecord: { paneKey: 'tab:leaf', capturedAt: 1, updatedAt: 1 } + } + const event = (index: number): AgentStatusIpcPayload => ({ + paneKey: index % 2 ? `other-${index}` : 'tab:leaf', + connectionId: null, + agentType: 'codex', + providerSession: refused.providerSession, + state: 'done', + prompt: '', + receivedAt: 10, + stateStartedAt: 1 + }) + let reads = 0 + let writes = 0 + const countQueue = (entries: T[]): T[] => { + entries.forEach((entry, index) => + Object.defineProperty(entries, index, { + configurable: true, + enumerable: true, + get: () => { + reads += 1 + return entry + }, + set: (next: T) => { + writes += 1 + entry = next + } + }) + ) + return entries + } + let copied = 0 + const countMap = (entries: T): T => + new Proxy(entries, { + ownKeys(target) { + const keys = Reflect.ownKeys(target) + copied += keys.length + return keys + } + }) + store.setState({ + agentStatusByPaneKey: countMap( + Object.fromEntries( + Array.from({ length: size }, (_, index) => { + const row = event(index) + return [ + index === 0 ? 'tab:leaf' : `other-${index}`, + { ...row, updatedAt: 10, stateHistory: [] } + ] + }) + ) + ), + sleepingAgentSessionsByPaneKey: countMap( + Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + index === 0 ? 'tab:leaf' : `other-${index}`, + { + paneKey: index === 0 ? 'tab:leaf' : `other-${index}`, + worktreeId: 'wt', + agent: 'codex', + providerSession: refused.providerSession, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1 + } + ]) + ) + ) + }) + const notifications = vi.fn() + const unsubscribe = store.subscribe(notifications) + const pending = countQueue( + Array.from({ length: size }, (_, index) => ({ + data: event(index), + firstSeenAt: 0, + replay: false + })) + ) + const burst = countQueue(Array.from({ length: size }, (_, index) => event(index))) + // Newer same-conversation work must survive the old refusal too. + burst[size - 1] = { ...event(0), receivedAt: 11 } + const onClear = vi.fn>(() => () => {}) + vi.stubGlobal('window', { + ...window, + api: { + ...window.api, + agentStatus: { + onSet: vi.fn(() => () => {}), + onClear + } + } + }) + const unsubs: (() => void)[] = [] + registerAgentStatusListeners({ + unsubs, + pendingAgentStatusEvents: pending, + liveAgentStatusBurstQueue: burst, + transientClearWatermarkByConnectionId: new Map(), + enqueueLiveAgentStatus: vi.fn(), + drainQueuedLiveAgentStatusesForPane: vi.fn() + }) + const clear = onClear.mock.calls[0]![0] + reads = 0 + writes = 0 + copied = 0 + try { + clear({ paneKey: 'tab:leaf', refusedResume: refused }) + const removed = size / 2 + // Reverse splice shifts 1+…+m surviving entries in EACH queue, besides one predicate read per row. + expect([reads, writes]).toEqual([3 * size + removed * (removed + 1), removed * (removed + 1)]) + expect(copied).toBe(2 * size) + expect(notifications).toHaveBeenCalledTimes(2) + expect([pending.length, burst.length]).toEqual([removed, removed]) + expect(burst.at(-1)?.receivedAt).toBe(11) + expect(store.getState().agentStatusByPaneKey['tab:leaf']).toBeUndefined() + expect(Object.keys(store.getState().sleepingAgentSessionsByPaneKey)).toHaveLength(size - 1) + notifications.mockClear() + copied = 0 + clear({ paneKey: 'tab:leaf', refusedResume: refused }) + expect(notifications).not.toHaveBeenCalled() + expect(copied).toBe(0) + } finally { + unsubs.forEach((dispose) => dispose()) + unsubscribe() + onClear.mockRestore() + } + } +) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8000b87e222..b3f6c4cf60d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3164,6 +3164,8 @@ "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenElsewhere": "Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.", + "conversationOpenInTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25e43028de4..395ab9b8baa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18979,6 +18979,8 @@ "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenElsewhere": "Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.", + "conversationOpenInTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", @@ -19468,8 +19470,8 @@ "showTerminal": "Show terminal" }, "resumeInNewChat": { - "exitUnconfirmedFromTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then open this conversation from Session History.", - "exitUnconfirmed": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + "exitUnconfirmedFromTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.", + "exitUnconfirmed": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.", "notSavedYet": "{{agent}} hasn't saved this conversation yet. Try again after it replies.", "cannotResume": "This {{agent}} conversation can't be resumed in a native chat." }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7a41bbdfe25..def963e176f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18644,6 +18644,8 @@ "claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.", + "conversationOpenElsewhere": "Codex sigue manteniendo abierta esta conversación. Inténtalo de nuevo en un momento o cierra otras terminales de Codex.", + "conversationOpenInTerminal": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal para continuar aquí.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", @@ -19034,8 +19036,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} aún no ha guardado esta conversación. Inténtalo de nuevo cuando responda.", "cannotResume": "Esta conversación de {{agent}} no se puede reanudar en un chat nativo.", - "exitUnconfirmed": "Orca no puede confirmar que {{agent}} haya salido de su terminal. Detenga lo que se está ejecutando allí o cierre la terminal y vuelva a intentarlo.", - "exitUnconfirmedFromTerminal": "Orca no puede confirmar que {{agent}} haya salido de su terminal. Detenga lo que se está ejecutando allí o cierre la terminal, luego abra esta conversación desde el Historial de sesiones." + "exitUnconfirmed": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal; luego abre esta conversación desde el Historial de sesiones.", + "exitUnconfirmedFromTerminal": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal; luego abre esta conversación desde el Historial de sesiones." }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f3b4a70f3b9..3c33d190330 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18784,6 +18784,8 @@ "claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.", + "conversationOpenElsewhere": "Codex maintient encore cette conversation ouverte. Réessayez dans un instant ou fermez les autres terminaux Codex.", + "conversationOpenInTerminal": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal pour continuer ici.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", @@ -19174,8 +19176,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} n'a pas encore enregistré cette conversation. Réessayez après sa réponse.", "cannotResume": "Cette conversation {{agent}} ne peut pas être reprise dans un chat natif.", - "exitUnconfirmedFromTerminal": "Orca ne peut pas confirmer que {{agent}} s’est arrêté dans son terminal. Arrêtez ce qui s’y exécute ou fermez le terminal, puis ouvrez cette conversation depuis l’historique des sessions.", - "exitUnconfirmed": "Orca ne peut pas confirmer que {{agent}} s’est arrêté dans son terminal. Arrêtez ce qui s’y exécute ou fermez le terminal, puis réessayez." + "exitUnconfirmedFromTerminal": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal, puis ouvrez cette conversation depuis l’historique des sessions.", + "exitUnconfirmed": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal, puis ouvrez cette conversation depuis l’historique des sessions." }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d2524c04625..8b2ee668ebb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18674,6 +18674,8 @@ "claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。", + "conversationOpenElsewhere": "Codex がまだこの会話を開いたままにしています。少し待ってからもう一度試すか、ほかの Codex ターミナルを閉じてください。", + "conversationOpenInTerminal": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じて、ここで続けてください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", @@ -19064,8 +19066,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} はまだこの会話を保存していません。応答してから再度お試しください。", "cannotResume": "この {{agent}} の会話はネイティブチャットで再開できません。", - "exitUnconfirmedFromTerminal": "Orca は {{agent}} がターミナルで終了したことを確認できません。実行中の処理を停止するかターミナルを閉じてから、セッション履歴でこの会話を開いてください。", - "exitUnconfirmed": "Orca は、{{agent}} がターミナルで終了したことを確認できません。そこで実行されているものを停止するか、ターミナルを閉じてから、もう一度試してください。" + "exitUnconfirmedFromTerminal": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じてから、セッション履歴でこの会話を開いてください。", + "exitUnconfirmed": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じてから、セッション履歴でこの会話を開いてください。" }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3caeb07a979..175c45fb7a0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18674,6 +18674,8 @@ "claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.", + "conversationOpenElsewhere": "Codex가 아직 이 대화를 열어 두고 있습니다. 잠시 후 다시 시도하거나 다른 Codex 터미널을 닫으세요.", + "conversationOpenInTerminal": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 후 여기서 계속하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", @@ -19064,8 +19066,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}}이(가) 아직 이 대화를 저장하지 않았습니다. 응답한 후 다시 시도하세요.", "cannotResume": "이 {{agent}} 대화는 기본 채팅에서 재개할 수 없습니다.", - "exitUnconfirmedFromTerminal": "Orca가 터미널에서 {{agent}} 종료 여부를 확인할 수 없습니다. 실행 중인 프로세스를 중지하거나 터미널을 닫은 후 세션 기록에서 이 대화를 여세요.", - "exitUnconfirmed": "Orca가 터미널에서 {{agent}} 종료 여부를 확인할 수 없습니다. 실행 중인 프로세스를 중지하거나 터미널을 닫은 후 다시 시도하세요." + "exitUnconfirmedFromTerminal": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 다음 세션 기록에서 이 대화를 여세요.", + "exitUnconfirmed": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 다음 세션 기록에서 이 대화를 여세요." }, "state": { "loading": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 51ff735797f..331764ca2ee 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19778,8 +19778,8 @@ }, "transcriptLabel": "Conversa", "resumeInNewChat": { - "exitUnconfirmedFromTerminal": "O Orca não consegue confirmar se o {{agent}} foi encerrado no terminal. Pare o que estiver em execução lá ou feche o terminal e abra esta conversa no Histórico de sessões.", - "exitUnconfirmed": "O Orca não consegue confirmar se o {{agent}} foi encerrado no terminal. Pare o que estiver em execução lá ou feche o terminal e tente novamente.", + "exitUnconfirmedFromTerminal": "{{agent}} já está executando esta conversa em outro terminal. Encerre o {{agent}} lá ou feche esse terminal e abra esta conversa no Histórico de sessões.", + "exitUnconfirmed": "{{agent}} já está executando esta conversa em outro terminal. Encerre o {{agent}} lá ou feche esse terminal e abra esta conversa no Histórico de sessões.", "notSavedYet": "O {{agent}} ainda não salvou esta conversa. Tente novamente depois que ele responder.", "cannotResume": "Esta conversa do {{agent}} não pode ser retomada em um chat nativo." }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e74636f3062..72bbea2fdfd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18639,6 +18639,8 @@ "claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。", + "conversationOpenElsewhere": "Codex 仍在保持此对话打开。请稍后重试,或关闭其他 Codex 终端。", + "conversationOpenInTerminal": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后在这里继续。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", @@ -19029,8 +19031,8 @@ "resumeInNewChat": { "notSavedYet": "{{agent}} 尚未保存此对话。请在它回复后重试。", "cannotResume": "此 {{agent}} 对话无法在本机聊天中恢复。", - "exitUnconfirmed": "Orca 无法确认 {{agent}} 已在其终端中退出。停止那里正在运行的程序或关闭终端,然后重试。", - "exitUnconfirmedFromTerminal": "Orca 无法确认 {{agent}} 已在其终端中退出。停止那里正在运行的程序或关闭终端,然后从会话历史记录中打开此对话。" + "exitUnconfirmed": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后从会话历史记录中打开此对话。", + "exitUnconfirmedFromTerminal": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后从会话历史记录中打开此对话。" }, "state": { "loading": { diff --git a/src/renderer/src/lib/cli-conversation-exit-guidance.ts b/src/renderer/src/lib/cli-conversation-exit-guidance.ts index 41da962ac58..fd50d3cb552 100644 --- a/src/renderer/src/lib/cli-conversation-exit-guidance.ts +++ b/src/renderer/src/lib/cli-conversation-exit-guidance.ts @@ -11,12 +11,12 @@ export function cliConversationExitGuidance( return fromTerminal ? translate( 'components.native-chat.resumeInNewChat.exitUnconfirmedFromTerminal', - "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then open this conversation from Session History.", + '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.', values ) : translate( 'components.native-chat.resumeInNewChat.exitUnconfirmed', - "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.', values ) } diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts new file mode 100644 index 00000000000..7372577bbce --- /dev/null +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -0,0 +1,66 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { RefusedPaneResume } from '../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' + +/** Whether a saved claim names the refused conversation. Only local claims can: the host judged + * its own chats, and a remote host's same id is a different conversation. */ +export function namesRefusedResume( + refused: RefusedPaneResume, + claim: { + agent?: string + providerSession?: AgentProviderSessionMetadata + connectionId?: string | null + } +): boolean { + return ( + !claim.connectionId && + claim.agent === refused.agent && + agentProviderSessionsEqual(refused.agent, claim.providerSession, refused.providerSession) + ) +} + +/** + * Main refused `paneKey`'s saved resume because a native chat owns that conversation. Forgets it as + * this pane's, whether or not the pane is mounted, so nothing offers or retries it here. Another + * pane holding the same conversation is refused and forgotten at its own restore. Main already + * forgot its own copy, so nothing is sent back. + */ +export function forgetRefusedPaneResume( + state: AppState, + paneKey: string, + refused: RefusedPaneResume +): void { + const expected = refused.expectedSavedRecord + const saved = expected && state.sleepingAgentSessionsByPaneKey[expected.paneKey] + if ( + expected && + saved && + saved.capturedAt === expected.capturedAt && + saved.updatedAt === expected.updatedAt && + namesRefusedResume(refused, saved) + ) { + state.clearSleepingAgentSession(expected.paneKey) + } + const identity = refused.expectedStatus + if (!identity) { + return + } + useAppStore.setState((current) => { + const live = current.agentStatusByPaneKey[paneKey] + if ( + !live || + live.updatedAt > identity.receivedAt || + live.stateStartedAt !== identity.stateStartedAt || + !namesRefusedResume(refused, { ...live, agent: live.agentType }) + ) { + return current + } + const next = { ...current.agentStatusByPaneKey } + delete next[paneKey] + return { agentStatusByPaneKey: next } + }) +} diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts index 7725700d97a..4bcc6f7015c 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts @@ -22,6 +22,7 @@ vi.mock('@/store', () => ({ sshConnectionStates: new Map(), sshStateByEnvironment: new Map(), runtimeStatusByEnvironmentId: new Map(), + agentStatusByPaneKey: {}, tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, openFiles: [], browserTabsByWorktree: {}, diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts index 2f249d9b2af..c39a3b4fba2 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -6,6 +6,7 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-r const mockCreateTab = vi.fn() const store = { + agentStatusByPaneKey: {}, settings: { agentCmdOverrides: {}, agentDefaultArgs: {} as Record, diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 5df77c13dc2..67b9a2bbf8e 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -78,6 +78,20 @@ export function launchSleepingAgentSession( ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, resumeProviderSession: record.providerSession, + resumeOrigin: 'saved', + resumeSource: { + cancelled: false, + record, + ...(state.agentStatusByPaneKey[record.paneKey] + ? { + expectedStatus: { + paneKey: record.paneKey, + receivedAt: state.agentStatusByPaneKey[record.paneKey].updatedAt, + stateStartedAt: state.agentStatusByPaneKey[record.paneKey].stateStartedAt + } + } + : {}) + }, launchAgent: record.agent, ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), ...(startupPlan.startupCommandDelivery diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index cbbe6b4d0e3..ffd34af378a 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -407,14 +407,14 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch.defaultTerminalTabsAppliedByWorktreeId).toBeUndefined() }) - it('keeps sleeping agent session clearing keys in patches', () => { + it('clears the last sleeping agent session with an empty map the host split keeps', () => { const patch = buildWorkspaceSessionPatch( createSnapshot({ sleepingAgentSessionsByPaneKey: {} }), ['sleepingAgentSessionsByPaneKey'] ) - expect(Object.hasOwn(patch, 'sleepingAgentSessionsByPaneKey')).toBe(true) - expect(patch.sleepingAgentSessionsByPaneKey).toBeUndefined() + // undefined would be dropped before the write, leaving the record on disk until quit. + expect(patch.sleepingAgentSessionsByPaneKey).toEqual({}) }) it('keeps a staged browser tab out of incremental patches too', () => { diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 38d3b008802..61599782ab4 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -200,8 +200,9 @@ export function buildWorkspaceSessionPatch( : undefined } if (changed.has('sleepingAgentSessionsByPaneKey')) { + // Why `{}`: the host split drops undefined, so clearing the last record never reached disk. patch.sleepingAgentSessionsByPaneKey = - buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey + buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey ?? {} } return patch diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index ddb32e7dfd7..86204f37f27 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -1,3 +1,4 @@ +import type { TerminalAttachmentLaunchResult } from '../../../shared/terminal-attachment-launch-result' import type { TerminalSnapshotUnavailableReason } from '../../../shared/terminal-snapshot-unavailability' import type { TerminalStreamEndVerdict } from '../../../shared/terminal-stream-end-verdict' import type { TerminalInputAckKind } from '../../../shared/terminal-stream-protocol' @@ -12,6 +13,8 @@ export type TerminalMultiplexEvent = | { type: 'ready' } | { type: 'subscribed' + terminal?: string + launchResult?: unknown streamId: number streamGeneration?: string capabilities?: { @@ -58,7 +61,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { ) => void onSubscribed?: () => void /** The host installed the stream and named its capabilities; its initial snapshot is still on the way. */ - onSubscribeAcknowledged?: () => void + onSubscribeAcknowledged?: (result?: TerminalAttachmentLaunchResult) => void onOutputPauseCapability?: () => void /** The host discarded output while this stream was paused (negotiated streams only). */ onPausedOutputDropped?: () => void diff --git a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts index cc2aacb43fb..3d96f0ce111 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts @@ -1,3 +1,4 @@ +import { parseTerminalAttachmentLaunchResult } from '../../../shared/terminal-attachment-launch-result' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR } from '../../../shared/terminal-multiplex-flow-control' import { parseTerminalStreamEndVerdict } from '../../../shared/terminal-stream-end-verdict' @@ -99,7 +100,11 @@ export abstract class RemoteRuntimeTerminalResponseController extends RemoteRunt stream.callbacks.onOutputPauseCapability?.() } if (!stream.initialSnapshotReceived) { - stream.callbacks.onSubscribeAcknowledged?.() + stream.callbacks.onSubscribeAcknowledged?.( + event.terminal === stream.terminal + ? parseTerminalAttachmentLaunchResult(event.launchResult) + : undefined + ) } } else if (event.type === 'end') { disposeRemoteTerminalStreamState(stream, 'Remote terminal stream ended.') diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 22253998daf..5717d1e9b55 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -208,6 +208,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null @@ -234,6 +235,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index c1bbfe88aa1..b012993af3e 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -6,6 +6,7 @@ import type { } from '../../../../shared/workspace-layout/workspace-layout-published' import type { TuiAgent } from '../../../../shared/tui-agent' import type { + SavedResumeStartupSource, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -63,6 +64,8 @@ export type TerminalState = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' + resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index e80cad3b5e6..1c935d6d911 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -78,6 +78,10 @@ export const AGENT_SESSION_FAILURE_COPY = { "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", runtimeNotInstalled: "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + conversationOpenElsewhere: + 'Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.', + conversationOpenInTerminal: + '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.', chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 86e90aafd8e..c9bd5cd5189 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -188,6 +188,8 @@ const FAILURE_SENTENCES = { claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'), agentCommandNotRunnable: namingAgent('agentCommandNotRunnable'), runtimeNotInstalled: namingAgent('runtimeNotInstalled'), + conversationOpenInTerminal: namingAgent('conversationOpenInTerminal'), + conversationOpenElsewhere: (_context, _fact, _surface, say) => say('conversationOpenElsewhere'), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index a42009fd8f0..13705c9c1d1 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -40,6 +40,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'claudeAccountSetupFailed', 'agentCommandNotRunnable', 'runtimeNotInstalled', + 'conversationOpenInTerminal', + 'conversationOpenElsewhere', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts index 571cda3d6fb..3f9eeed00ba 100644 --- a/src/shared/agent-session-host-authority.ts +++ b/src/shared/agent-session-host-authority.ts @@ -105,6 +105,7 @@ export type RuntimeEnsureAgentSessionRequest = } | { kind: 'explicit' + resumeOrigin?: 'saved' worktree: string agent: ResumableTuiAgent providerSession: AgentProviderSessionMetadata diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 4ba2855bb42..6f9b6734161 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -85,6 +85,9 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'agentCommandNotRunnable', /** The runtime Orca installs for the agent is not installed on this host. */ 'runtimeNotInstalled', + /** A terminal may still be writing the chat's conversation, so its agent did not start. */ + 'conversationOpenInTerminal', + 'conversationOpenElsewhere', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index c2ce2cadab1..8a5b597e411 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -116,6 +116,8 @@ const REASON_WORDS = { claudeAccountSetupFailed: { fact: 'claudeAccountSetupFailed', action: 'retry' }, agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, + conversationOpenInTerminal: { fact: 'conversationOpenInTerminal', action: 'actFirst' }, + conversationOpenElsewhere: { fact: 'conversationOpenElsewhere', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 393cf55d971..f6a5a71158d 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -1,4 +1,5 @@ import type { AgentHookSource } from './agent-hook-relay' +import type { AgentStatusCacheIdentity, RefusedPaneResume } from './agent-status-ipc-payload' import type { AgentStatusState } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' import type { TuiAgent } from './tui-agent' @@ -307,3 +308,45 @@ export function extractAgentProviderSession( // Preserve the existing import path. export { getAgentForkArgv, getAgentResumeArgv } from './agent-resume-argv' + +/** A host home check for this exact saved continuation, not a terminal writer claim. */ +export type SavedResumeDecision = { + agent: string + providerSession: AgentProviderSessionMetadata + verdict: 'admitted' | 'chat-home' | 'chat-writing' | 'unverifiable' +} + +/** Effective facts of a terminal launch; a refused shell carries no rejected agent metadata. */ +export type TerminalLaunchResult = { + savedResumeDecision?: SavedResumeDecision + agentResumeUnavailable?: true + agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' + refusedResume?: RefusedPaneResume + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + launchToken?: string + resumeProviderSession?: AgentProviderSessionMetadata +} + +/** Runtime-only source shared by copies of one saved startup. Never persisted or sent to a host. */ +export type SavedResumeStartupSource = { + cancelled: boolean + record?: SleepingAgentSessionRecord + expectedStatus?: AgentStatusCacheIdentity +} + +/** Forward only effective launch facts through each existing terminal envelope. */ +export function terminalLaunchFacts(result: TerminalLaunchResult): TerminalLaunchResult { + return { + ...(result.savedResumeDecision ? { savedResumeDecision: result.savedResumeDecision } : {}), + ...(result.agentResumeUnavailable + ? { agentResumeUnavailable: result.agentResumeUnavailable } + : {}), + ...(result.agentResumeRefused ? { agentResumeRefused: result.agentResumeRefused } : {}), + ...(result.refusedResume ? { refusedResume: result.refusedResume } : {}), + ...(result.launchConfig ? { launchConfig: result.launchConfig } : {}), + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + ...(result.launchToken ? { launchToken: result.launchToken } : {}), + ...(result.resumeProviderSession ? { resumeProviderSession: result.resumeProviderSession } : {}) + } +} diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 6885df76d33..09a495879fe 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -85,6 +85,14 @@ export type AgentStatusCacheIdentity = { stateStartedAt: number } +/** A pane's saved resume the host refused because a native chat is that conversation's home. */ +export type RefusedPaneResume = { + expectedStatus?: AgentStatusCacheIdentity + expectedSavedRecord?: { paneKey: string; capturedAt: number; updatedAt: number } + agent: string + providerSession: AgentProviderSessionMetadata +} + /** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ export type AgentStatusClearIpcPayload = | { @@ -92,6 +100,8 @@ export type AgentStatusClearIpcPayload = statusUnavailable?: true /** An OSC 7501 program took back its own row, so even a `done` row goes. */ programStatusRemoved?: true + /** Forget that conversation as this pane's resume target too, not only its status. */ + refusedResume?: RefusedPaneResume } | { transient: true diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0e2ec77f74c..109fac80b9d 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -41,7 +41,8 @@ export type { AgentStatusCacheIdentity, AgentStatusClearIpcPayload, AgentStatusIpcPayload, - MigrationUnsupportedPtyEntry + MigrationUnsupportedPtyEntry, + RefusedPaneResume } from './agent-status-ipc-payload' export { mainAgentStatusEqual, type AgentMainAgentStatus } from './main-agent-status' export { AGENT_STATE_HISTORY_MAX, type AgentStateHistoryEntry } from './agent-state-history' diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 6b3b6f19f54..98cd8be9dda 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -119,7 +119,11 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ] as const // Each area lists what it publishes in its own module; a new capability edits only that module. +export const SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY = + 'agent-session.saved-resume-origin.v1' as const + export const RUNTIME_CAPABILITIES = [ + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, ...HOST_RUNTIME_CAPABILITIES, ...DEVICE_KEY_RUNTIME_CAPABILITIES, ...MOBILE_RUNTIME_CAPABILITIES, diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index b93eaccfdea..15a98d809e6 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -129,6 +129,7 @@ export const AutomaticEnsure = z export const ExplicitEnsure = z .object({ kind: z.literal('explicit'), + resumeOrigin: z.literal('saved').optional(), worktree: WorktreeSelector, agent: z.enum(RESUMABLE_TUI_AGENTS), providerSession: ProviderSession, diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index 5a27ab652c0..4c665a5b736 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -211,6 +211,8 @@ export const TerminalCreateParams = z.object({ }) .optional(), resumeProviderSession: ResumeProviderSessionParam.optional(), + // Older hosts drop it and resume as before. + resumeOrigin: z.literal('saved').optional(), launchToken: OptionalString, launchAgent: z.string().refine(isTuiAgent).optional(), terminalKittyKeyboardProtocol: z.boolean().optional(), diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 005d3e5e650..630865ca5b1 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { TerminalLaunchResult } from './agent-session-resume' import type * as AgentSessionResume from './agent-session-resume' import type { StartupCommandDelivery } from './codex-startup-delivery' import type { ExecutionHostId } from './execution-host' @@ -5,7 +6,6 @@ import type { PtyIncarnationId } from './pty-incarnation' import type { PtyLivenessVerdict } from './pty-liveness-verdict' import type { RuntimeListingHostScope } from './runtime-listing-host-scope' import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts' -import type { StartupCwdFallback } from './startup-cwd-fallback' import type { TabGroupLayoutNode } from './tab-types' import type { TerminalExitCause } from './terminal-exit-cause' import type { TerminalPaneLayoutNode } from './terminal-tab-types' @@ -268,28 +268,7 @@ export type RuntimeTerminalCreateRequestPayload = source: 'runtime-session' }) -export type RuntimeTerminalCreate = { - handle: string - /** Host-owned PTY incarnation used to fence remote identity observations. */ - incarnationId?: string | null - tabId?: string - paneKey?: string | null - ptyId?: string | null - worktreeId: string - title: string | null - executionHostId?: ExecutionHostId - hostPlatform?: NodeJS.Platform - surface?: 'background' | 'visible' - warning?: string - agentSessionDisposition?: 'created' | 'adopted' - isReattach?: true - /** Spawn process identity for host-internal ownership proof. */ - processId?: number - /** Host-internal: the requested folder was gone, so the process started at the workspace root. */ - startupCwdFallback?: StartupCwdFallback - /** Host-internal saved-folder decision; excluded by RPC result projection. */ - savedStartupCwd?: string -} +export type { RuntimeTerminalCreate } from './runtime-terminal-create-contract' export type RuntimeTerminalSplit = { handle: string @@ -299,7 +278,7 @@ export type RuntimeTerminalSplit = { leafId?: string } -export type RuntimeTerminalResolvePane = { +export type RuntimeTerminalResolvePane = TerminalLaunchResult & { handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string | null diff --git a/src/shared/runtime-terminal-create-contract.ts b/src/shared/runtime-terminal-create-contract.ts new file mode 100644 index 00000000000..e4fc3e55ca5 --- /dev/null +++ b/src/shared/runtime-terminal-create-contract.ts @@ -0,0 +1,27 @@ +import type { TerminalLaunchResult } from './agent-session-resume' +import type { ExecutionHostId } from './execution-host' +import type { StartupCwdFallback } from './startup-cwd-fallback' + +export type RuntimeTerminalCreate = TerminalLaunchResult & { + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + handle: string + /** Host-owned PTY incarnation used to fence remote identity observations. */ + incarnationId?: string | null + tabId?: string + paneKey?: string | null + ptyId?: string | null + worktreeId: string + title: string | null + executionHostId?: ExecutionHostId + hostPlatform?: NodeJS.Platform + surface?: 'background' | 'visible' + warning?: string + agentSessionDisposition?: 'created' | 'adopted' + isReattach?: true + /** Spawn process identity for host-internal ownership proof. */ + processId?: number + /** Host-internal: the requested folder was gone, so the process started at the workspace root. */ + startupCwdFallback?: StartupCwdFallback + /** Host-internal saved-folder decision; excluded by RPC result projection. */ + savedStartupCwd?: string +} diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 8e2c5bdf198..2ca8308cac6 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -80,6 +80,8 @@ const KIND_CATEGORY = { claudeAccountSetupFailed: 'startFailed', agentCommandNotRunnable: 'startFailed', runtimeNotInstalled: 'startFailed', + conversationOpenInTerminal: 'startFailed', + conversationOpenElsewhere: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -113,6 +115,8 @@ const KIND_VERDICT = { claudeAccountSetupFailed: 'failure', agentCommandNotRunnable: 'failure', runtimeNotInstalled: 'failure', + conversationOpenInTerminal: 'failure', + conversationOpenElsewhere: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', diff --git a/src/shared/terminal-attachment-launch-result.ts b/src/shared/terminal-attachment-launch-result.ts new file mode 100644 index 00000000000..21a050d9b52 --- /dev/null +++ b/src/shared/terminal-attachment-launch-result.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import type { TerminalLaunchResult } from './agent-session-resume' +import { isTuiAgent } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' +import { ResumeProviderSessionParam } from './rpc-contract/terminal-unary-params' +import { sleepingAgentLaunchConfigBaseSchema } from './workspace-session-sleeping-agents' + +export type TerminalAttachmentLaunchResult = TerminalLaunchResult & { + ptyId: string + incarnationId: string +} + +const launchResultSchema = z.object({ + ptyId: z.string().min(1), + incarnationId: z.string().min(1), + savedResumeDecision: z + .object({ + agent: z.string().min(1), + providerSession: ResumeProviderSessionParam, + verdict: z.enum(['admitted', 'chat-home', 'chat-writing', 'unverifiable']) + }) + .optional(), + agentResumeUnavailable: z.literal(true).optional(), + agentResumeRefused: z.enum(['chat-home', 'chat-writing', 'unverifiable']).optional(), + refusedResume: z + .object({ + agent: z.string().min(1), + providerSession: ResumeProviderSessionParam, + expectedStatus: z + .object({ + paneKey: z.string().min(1), + receivedAt: z.number().finite(), + stateStartedAt: z.number().finite() + }) + .optional(), + expectedSavedRecord: z + .object({ + paneKey: z.string().min(1), + capturedAt: z.number().finite(), + updatedAt: z.number().finite() + }) + .optional() + }) + .optional(), + launchConfig: sleepingAgentLaunchConfigBaseSchema.optional(), + launchAgent: z.custom(isTuiAgent).optional(), + launchToken: z.string().min(1).optional(), + resumeProviderSession: ResumeProviderSessionParam.optional() +}) + +export function parseTerminalAttachmentLaunchResult( + value: unknown +): TerminalAttachmentLaunchResult | undefined { + const result = launchResultSchema.safeParse(value) + return result.success ? result.data : undefined +} diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index e8fe4aaf735..a6bfc11a8d0 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -63,7 +63,7 @@ const sleepingAgentLaunchEnvSchema = z.preprocess( z.record(z.string(), z.string()) ) -const sleepingAgentLaunchConfigBaseSchema = z.object({ +export const sleepingAgentLaunchConfigBaseSchema = z.object({ agentCommand: z.string().optional(), agentArgs: z.string(), agentEnv: sleepingAgentLaunchEnvSchema, diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts index edb2eb973ff..45a9ee7f9ab 100644 --- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts +++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts @@ -108,6 +108,8 @@ export function createHostTerminalRuntimeStub( const runtime: Record = { getRuntimeId: () => 'cross-version-host', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, resolveLiveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null), resolveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null), registerRemoteTerminalViewSubscriber: () => () => {}, diff --git a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts index 5d5570b7b4b..0b6df14998a 100644 --- a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts +++ b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts @@ -117,7 +117,8 @@ async function hostSurfaceStatus( page: Page, environmentId: string, worktreeId: string, - parentTabId: string + parentTabId: string, + expectedLeafId?: string ): Promise { const snapshot = await callRuntime( page, @@ -126,25 +127,29 @@ async function hostSurfaceStatus( { worktree: `id:${worktreeId}` } ) const surface = snapshot.tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === parentTabId && + (expectedLeafId === undefined || candidate.leafId === expectedLeafId) ) return surface?.type === 'terminal' ? surface.status : null } -// Retry until the host publishes the parked pending handle; stop verification can miss PTY exit under headless load. async function parkHostTerminal( page: Page, environmentId: string, worktreeId: string, parentTabId: string, - options: { expectedPtyId: string } + options: { expectedPtyId: string; original: RuntimeTerminalShow } ): Promise { let lastError = 'terminal.stopExact was never attempted' for (let attempt = 0; attempt < 10; attempt += 1) { + const stopStartedAt = Date.now() const stop = await callRuntime<{ stopped: number stoppedPtyIds: string[] postStopVerified: boolean + postStopFailure?: string }>(page, environmentId, 'terminal.stopExact', { worktree: `id:${worktreeId}`, expectedPtyIds: [options.expectedPtyId], @@ -155,9 +160,57 @@ async function parkHostTerminal( return null }) if (stop) { - expect(stop.postStopVerified).toBe(true) - expect(stop.stopped).toBe(1) - expect(stop.stoppedPtyIds).toEqual([options.expectedPtyId]) + const diagnostic: Record = { + expectedPtyId: options.expectedPtyId, + expectedHostId: options.original.executionHostId ?? null, + expectedLeafId: options.original.leafId, + elapsedMs: Date.now() - stopStartedAt, + stop + } + let parked = stop.postStopVerified === true + if (stop.postStopVerified === false) { + const inventory = await readFreshTerminalInventory(() => + callRuntime(page, environmentId, 'terminal.list', { + requireFreshPtyLiveness: true + }) + ).catch((error: unknown) => ({ error: String(error) })) + const surface = await hostSurfaceStatus( + page, + environmentId, + worktreeId, + parentTabId, + options.original.leafId + ) + .then((status) => ({ parentTabId, leafId: options.original.leafId, status })) + .catch((error: unknown) => ({ parentTabId, error: String(error) })) + Object.assign(diagnostic, { inventory, surface }) + parked = + stop.postStopFailure === 'terminal_liveness_unavailable' && + inventory !== null && + 'terminals' in inventory && + inventory.truncated === false && + inventory.totalCount === inventory.terminals.length && + options.original.executionHostId !== undefined && + inventory.hostScope?.hostIds.includes(options.original.executionHostId) === true && + inventory.hostScope.omittedHostIds.length === 0 && + !inventory.terminals.some((terminal) => terminal.ptyId === options.expectedPtyId) && + options.original.tabId === parentTabId && + 'status' in surface && + surface.status === 'pending-handle' + } + await test + .info() + .attach('host-terminal-park', { + body: JSON.stringify(diagnostic, null, 2), + contentType: 'application/json' + }) + .catch((error: unknown) => { + diagnostic.attachmentError = String(error) + }) + const evidence = JSON.stringify(diagnostic, null, 2) + expect(stop.stopped, evidence).toBe(1) + expect(stop.stoppedPtyIds, evidence).toEqual([options.expectedPtyId]) + expect(parked, evidence).toBe(true) return } // Set mismatch also confirms the no-longer-live PTY is parked when stop verification misses exit. @@ -224,7 +277,8 @@ async function runMaterializationJourney( gate.holdEnd([terminal]) }, originalHandle) await parkHostTerminal(page, environmentId, worktreeId, created.tab.parentTabId, { - expectedPtyId: originalTerminal.terminal.ptyId + expectedPtyId: originalTerminal.terminal.ptyId, + original: originalTerminal.terminal }) // Require an already-parked surface before applying the stale error. await expect diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index 63d5a369151..366ce77fbd3 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -80,8 +80,11 @@ function startHost(): { } { const hostOpcodes: number[] = [] const sendTerminal = vi.fn().mockResolvedValue({ accepted: false }) + // 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),