From 2315bc2a394fb3b8f985d9b70784d2d3bf659963 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 19:39:25 -0400 Subject: [PATCH] fix(terminal): revalidate settled pane fences and negotiate host refusal --- config/reliability-gates.jsonc | 5 +- .../pty-attach-only-fenced-pane-spawn.test.ts | 128 ++++++++++++++++++ .../ipc/pty-runtime-kill-and-exit.test.ts | 25 ++++ src/main/ipc/pty/pane/stable-owner.ts | 30 ++-- .../pane/stable-pane-fenced-evidence.test.ts | 29 ++++ .../stable-pane-resume-fence-host.test.ts | 17 +++ .../ipc/pty/pane/stable-pane-resume-fence.ts | 2 + src/main/ipc/pty/runtime/spawn-execute.ts | 25 +++- ...ntime-pty-transport-fenced-outcome.test.ts | 41 ++++++ .../remote-runtime-pty-transport.ts | 14 +- src/shared/protocol-version.ts | 2 + .../remote-runtime-client-capabilities.ts | 2 + .../terminal-fenced-create-capability.test.ts | 8 ++ 13 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts create mode 100644 src/shared/terminal-fenced-create-capability.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 04a8e56a3c0..f1da90b6e01 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -25,13 +25,16 @@ "coverageNotes": "macOS hidden Electron restart exercises live and absent daemon sessions. Unit contracts cover direct SSH evidence and paired host/client refusal; other operating systems and real remote topologies remain unverified.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/19358"], "invariant": "Main reads the persisted pane resume fence and permits attachment only. Observed exit preserves the historical tab and binding; unverifiable evidence preserves ownership and permits recovery without replacement.", - "oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. Unsupported paired resolution never certifies exit.", + "oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. A fence committed during attachment or claim reconciliation prevents retirement and fresh provider spawn. Unsupported paired resolution never certifies exit; owner absence cannot create until host fenced-create semantics are negotiated.", "commands": [ "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts", "ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 npx playwright test tests/e2e/settled-worker-tab-survives-restart.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --retries=0" ], "testFiles": [ "src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts", + "src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts", "tests/e2e/settled-worker-tab-survives-restart.spec.ts" ], "assertionRefs": [ diff --git a/src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts b/src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts index db8babf5996..bce6f88aa40 100644 --- a/src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts +++ b/src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts @@ -105,6 +105,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => { beginPtyRegistration: vi.fn(), cancelPendingPtyRegistration: vi.fn(), assertPtyRegistrationAllowed: vi.fn(), + preparePtyExecutionContext: vi.fn(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), seedHeadlessTerminal: vi.fn(), @@ -176,6 +177,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => { expect(runtime.beginPtyRegistration).not.toHaveBeenCalled() expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false) expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() expect(providerSpawn).toHaveBeenCalledTimes(1) expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true) @@ -226,6 +229,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => { expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(store.persistPtyBinding).not.toHaveBeenCalled() expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() } ) @@ -285,6 +290,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => { const { store, runtime, spawnArgs } = buildFencedPaneContext('execute-refusal') const earlyAdoption = vi.spyOn(stableAdoption, 'adoptStablePane').mockResolvedValueOnce(null) const providerSpawn = vi.fn(async () => { + runtime.preparePtyExecutionContext.mockClear() throw new SessionNotFoundError(spawnArgs.sessionId) }) installDaemonTestProvider({ spawn: providerSpawn }) @@ -304,6 +310,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => { expect(providerSpawn).toHaveBeenCalledTimes(1) expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId) expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(store.persistPtyBinding).not.toHaveBeenCalled() expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false) @@ -318,6 +326,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => { async (ensureClaim) => { const { store, runtime, spawnArgs } = buildFencedPaneContext('runtime-absent-worker') const providerSpawn = vi.fn(async () => { + runtime.preparePtyExecutionContext.mockClear() throw new SessionNotFoundError(spawnArgs.sessionId) }) installDaemonTestProvider({ spawn: providerSpawn }) @@ -345,10 +354,129 @@ describe('pty:spawn under a persisted main-owned resume fence', () => { }) expect(providerSpawn).toHaveBeenCalledTimes(1) expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId) expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(store.persistPtyBinding).not.toHaveBeenCalled() expect(paneSpawnReservationsByOwnerKey.size).toBe(0) } ) + it.each([false, true])( + 'carries unverifiable through the runtime controller (pre-adopted: %s)', + async (preAdopted) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('unverifiable-controller') + const spawn = vi.fn(async () => { + throw new Error('daemon unavailable') + }) + installDaemonTestProvider({ spawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + const result = await controller.spawn({ + ...spawnArgs, + ...(preAdopted + ? { + adoptedStablePane: { + result: { id: spawnArgs.sessionId, reattachUnverifiable: true }, + owner: { + ptyId: spawnArgs.sessionId, + tabId: spawnArgs.tabId, + leafId: spawnArgs.leafId + } + } + } + : {}) + }) + expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true }) + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + if (preAdopted) { + expect(spawn).not.toHaveBeenCalled() + expect(runtime.beginPtyRegistration).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled() + } + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + } + ) + it.each(['ipc', 'runtime'] as const)( + 'preserves a fence committed during %s attach', + async (entry) => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('late-fenced-worker') + const record = + store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[ + makePaneKey(spawnArgs.tabId, spawnArgs.leafId) + ] + record.automaticResumeBlockedBy = '' + const spawn = vi.fn(async () => { + record.automaticResumeBlockedBy = 'legacy-orchestration-worker' + throw new SessionNotFoundError(spawnArgs.sessionId) + }) + installDaemonTestProvider({ spawn }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + const result = + entry === 'ipc' + ? await handlers.get('pty:spawn')!(null, spawnArgs) + : await controller.spawn(spawnArgs) + expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + } + ) + it('refuses a claim spawn when the fence commits during owner reconciliation', async () => { + const { store, runtime, spawnArgs } = buildFencedPaneContext('late-claim-fence') + const record = + store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[ + makePaneKey(spawnArgs.tabId, spawnArgs.leafId) + ] + record.automaticResumeBlockedBy = '' + const spawn = vi.fn(async () => ({ id: 'replacement' })) + const listProcesses = vi.fn(async () => { + record.automaticResumeBlockedBy = 'legacy-orchestration-worker' + return [] + }) + installDaemonTestProvider({ spawn, listProcesses }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]![0] as { + spawn: (args: unknown) => Promise + } + await expect( + controller.spawn({ + ...spawnArgs, + agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface } + }) + ).resolves.toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true }) + expect(listProcesses).toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId) + expect(paneSpawnReservationsByOwnerKey.size).toBe(0) + }) }) diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index 2200b2b6043..3786ee3c23f 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -542,4 +542,29 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() } }) + it.each(['renderer', 'runtime'] as const)( + '%s kill treats daemon session-not-found as completed shutdown', + async (entry) => { + const id = 'daemon-missing-session' + const shutdown = vi.fn(async () => { + throw new Error(`Session not found: ${id}`) + }) + installDaemonTestProvider({ shutdown }) + setPtyOwnership(id, null) + const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + if (entry === 'renderer') { + await expect(handlers.get('pty:kill')!(null, { id })).resolves.toBeUndefined() + } else { + const controller = runtime.setPtyController.mock.calls[0]![0] as { + kill: (id: string) => boolean + } + expect(controller.kill(id)).toBe(true) + } + await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalled()) + expect(shutdown).toHaveBeenCalledTimes(1) + const { ptyOwnership } = await import('./pty/provider/ownership-state') + expect(ptyOwnership.has(id)).toBe(false) + } + ) }) diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 48eed177700..6f1574833d1 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,6 +1,6 @@ import { isStablePaneResumeBlocked } from './stable-pane-resume-fence' import { toSshExecutionHostId } from '../../../../shared/execution-host' -import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause' import type { Store } from '../../../persistence' import { retirePersistedStablePaneOwner } from './stable-owner-retirement' @@ -25,7 +25,6 @@ export type StablePaneOwner = { hasPersistedBinding?: true persistedIncarnationId?: string runtimeIncarnationId?: string - automaticResumeBlocked?: true } export type StablePaneAdoption = { result: PtySpawnResult @@ -116,9 +115,6 @@ export function resolveStablePaneOwner( ...(runtimeIncarnationId || persisted?.incarnationId ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } : {}), - ...(isStablePaneResumeBlocked(store, paneKey, worktreeId, connectionId) - ? { automaticResumeBlocked: true as const } - : {}), ...(persisted ? { hasPersistedBinding: true as const } : {}), ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) @@ -182,6 +178,13 @@ export async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { const { owner, provider, runtime, spawnOptions } = args + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const blockedAtAttach = isStablePaneResumeBlocked( + args.store, + paneKey, + args.worktreeId, + args.connectionId + ) let result: PtySpawnResult try { result = await provider.spawn({ @@ -201,12 +204,12 @@ export async function attachStablePaneOwner( onPtySpawnCommitted: undefined }) } catch (error) { - if (owner.automaticResumeBlocked) { + if (isStablePaneResumeBlocked(args.store, paneKey, args.worktreeId, args.connectionId)) { return { owner, result: { id: owner.ptyId, - ...(isObservedPtyExitEvidence(error) + ...(blockedAtAttach && isObservedPtyExitEvidence(error) ? { exitedBeforeAttach: true as const } : { reattachUnverifiable: true as const }) } @@ -271,8 +274,13 @@ export async function attachStablePaneOwner( export async function spawnForStablePane( args: StablePaneSpawnContext ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { + if (args.owner) { + const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) + if (attached) { + return attached + } + } if ( - !args.owner && isStablePaneResumeBlocked( args.store, args.spawnOptions.paneKey, @@ -285,12 +293,6 @@ export async function spawnForStablePane( owner: null } } - if (args.owner) { - const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) - if (attached) { - return attached - } - } const result = await args.provider.spawn(args.spawnOptions) args.onFreshSpawn?.(result) return { result, owner: null } diff --git a/src/main/ipc/pty/pane/stable-pane-fenced-evidence.test.ts b/src/main/ipc/pty/pane/stable-pane-fenced-evidence.test.ts index 8102bd79b9a..22a4ce203ca 100644 --- a/src/main/ipc/pty/pane/stable-pane-fenced-evidence.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-fenced-evidence.test.ts @@ -94,4 +94,33 @@ describe('stable-pane fenced attach evidence', () => { expect(spawn).not.toHaveBeenCalled() expect(store.setWorkspaceSession).not.toHaveBeenCalled() }) + it('rechecks a fence committed while attachment is awaiting the host', async () => { + const { store, ptyId } = fixture(null) + const record = store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey] + delete (record as { automaticResumeBlockedBy?: string }).automaticResumeBlockedBy + const resolveOwner = () => + resolveStablePaneOwner(undefined, store as unknown as Store, paneKey, worktreeId, null) + const owner = resolveOwner() + let calls = 0 + const spawn = vi.fn(async () => { + if (++calls === 1) { + record.automaticResumeBlockedBy = 'legacy-orchestration-worker' + throw new SessionNotFoundError(ptyId) + } + return { id: 'replacement-after-fence' } + }) + const result = await spawnForStablePane({ + runtime: undefined, + store: store as unknown as Store, + provider: { spawn } as unknown as IPtyProvider, + owner, + worktreeId, + connectionId: null, + resolveOwner, + spawnOptions: { cols: 80, rows: 24, paneKey } + }) + expect(result.result).toEqual({ id: ptyId, reattachUnverifiable: true }) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts b/src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts new file mode 100644 index 00000000000..a276414ef25 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest' +import { isStablePaneResumeBlocked } from './stable-pane-resume-fence' + +describe('review host-scoped fence', () => { + it('reads SSH policy when the identical local pane is not fenced', () => { + const paneKey = 'tab-worker:5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b' + const worktreeId = 'folder-worker' + const local = { sleepingAgentSessionsByPaneKey: {} } + const remote = { + sleepingAgentSessionsByPaneKey: { + [paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' } + } + } + const store = { getWorkspaceSession: vi.fn((host) => (host === 'ssh:host' ? remote : local)) } + expect(isStablePaneResumeBlocked(store as never, paneKey, worktreeId, 'host')).toBe(true) + }) +}) diff --git a/src/main/ipc/pty/pane/stable-pane-resume-fence.ts b/src/main/ipc/pty/pane/stable-pane-resume-fence.ts index 34338fc2d36..2f8f2e512c1 100644 --- a/src/main/ipc/pty/pane/stable-pane-resume-fence.ts +++ b/src/main/ipc/pty/pane/stable-pane-resume-fence.ts @@ -17,3 +17,5 @@ export function isStablePaneResumeBlocked( worktreeId ) } + +export class StablePaneResumeBlockedError extends Error {} diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 4003f6be67d..12439cb78a5 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -1,3 +1,7 @@ +import { + isStablePaneResumeBlocked, + StablePaneResumeBlockedError +} from '../pane/stable-pane-resume-fence' import type { PtySpawnResult } from '../../../providers/types' import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' @@ -62,7 +66,12 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise if ( args.agentSessionEnsure && !ctx.preAdoptedStablePane && - !stablePaneOwnerCandidate?.automaticResumeBlocked + !isStablePaneResumeBlocked( + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) ) { // Why: daemon-backed claims can outlive this controller; import all // proven owners before deciding that an identity is absent. @@ -84,6 +93,16 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise surface: args.agentSessionEnsure.surface, spawn: async () => { assertClientStillConnected() + if ( + isStablePaneResumeBlocked( + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + ) { + throw new StablePaneResumeBlockedError() + } 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. @@ -201,6 +220,10 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise : ctx.result.wslDistro ) } catch (err) { + if (err instanceof StablePaneResumeBlockedError) { + ctx.result = { id: ctx.sessionId ?? '', reattachUnverifiable: true } + return + } if ( (ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) && ctx.effectiveSessionAppId diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts index e3c3c61a9ca..40677d45aff 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts @@ -1,3 +1,4 @@ +import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, @@ -26,6 +27,9 @@ describe('paired host attach evidence', () => { 'carries host %s without subscribing or publishing a spawn', async (outcome) => { runtimeCall.mockImplementation(async ({ method }: { method: string }) => { + if (method === 'status.get') { + return { ok: true, result: { capabilities: [TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY] } } + } if (method === 'terminal.resolvePane') { return { ok: false, @@ -142,4 +146,41 @@ describe('paired host attach evidence', () => { await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) transport.destroy?.() }) + it('an old host with resolvePane must not create a replacement for a retained pane', async () => { + runtimeCall.mockImplementation(async ({ method }: { method: string }) => { + if (method === 'terminal.resolvePane') { + return { ok: false, error: { code: 'terminal_not_found', message: 'terminal_not_found' } } + } + return { + ok: true, + result: { + terminal: { + handle: 'replacement', + ptyId: 'replacement-pty', + tabId: 'tab-1', + paneKey: 'tab-1:pane:1' + } + } + } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const result = await transport.connect({ + url: '', + sessionId: 'remote:env-1@@retained-handle', + callbacks: {} + }) + expect(result).toEqual({ id: 'remote:env-1@@retained-handle', reattachUnverifiable: true }) + try { + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + } finally { + transport.destroy?.() + } + }) }) 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 ef1ae7f696e..648f5947c3c 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 @@ -19,7 +19,10 @@ import type { RuntimeTerminalResolvePane, RuntimeTerminalSend } from '../../../../shared/runtime-types' -import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { agentResumeHostAuthorityCapability } from '../../runtime/agent-resume-host-authority-capability' import { isTerminalInputTooLargeWithDeferredMeasurement, @@ -2234,6 +2237,15 @@ export function createRemoteRuntimePtyTransport( } return { id: options.sessionId, reattachUnverifiable: true } } + const status = await callRuntime('status.get') + if (destroyed || lifecycleEpoch !== connectLifecycleEpoch) { + return + } + if (!status.capabilities?.includes(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)) { + connecting = false + emitRecoveryState() + return { id: options.sessionId, reattachUnverifiable: true } + } } const commandToSend = options.command ?? command const startupCommandDeliveryToSend = diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e1cf7594034..99878a1c3a4 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -116,6 +116,7 @@ export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'worktree.create-idempotency.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const +export const TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY = 'terminal.fenced-create.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const @@ -250,6 +251,7 @@ export const RUNTIME_CAPABILITIES = [ TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY, WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 3797f08ffb9..234521db640 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,4 +1,5 @@ import { + TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, @@ -18,6 +19,7 @@ export function remoteRuntimeClientCapabilities( new Set([ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/terminal-fenced-create-capability.test.ts b/src/shared/terminal-fenced-create-capability.test.ts new file mode 100644 index 00000000000..3b7d7c9752d --- /dev/null +++ b/src/shared/terminal-fenced-create-capability.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from 'vitest' +import { RUNTIME_CAPABILITIES, TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from './protocol-version' +import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities' + +it('advertises fenced create outcomes in host status and every paired client transport', () => { + expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY) + expect(remoteRuntimeClientCapabilities()).toContain(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY) +})