fix terminal incarnation propagation on recovery

This commit is contained in:
Brennan Benson
2026-08-26 21:51:37 -07:00
parent 01b33682b2
commit 6084cd077f
14 changed files with 132 additions and 15 deletions
+34 -2
View File
@@ -10471,6 +10471,37 @@ describe('OrcaRuntimeService', () => {
expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ kind: 'agent-exited' })
})
it('confirms a host-certified SSH exit that races the foreground read', async () => {
const { runtime, batches } = createSideEffectRuntime()
runtime.registerPty('pty-1', TEST_WORKTREE_ID, 'conn-1')
syncSinglePty(runtime)
runtime.onPtySpawned('pty-1', 'inc-ssh-exit', { awaitsRegistration: false })
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
let resolveForeground!: (process: string) => void
const foregroundRead = new Promise<string>((resolve) => {
resolveForeground = resolve
})
const getForegroundProcess = vi.fn(() => foregroundRead)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce())
runtime.onPtyExit('pty-1', -1, 'inc-ssh-exit', { hostExitConfirmed: true })
resolveForeground('zsh')
await vi.waitFor(() =>
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({
kind: 'agent-exited',
executionHostConfirmed: true,
incarnationId: 'inc-ssh-exit'
})
)
})
it('still confirms a local agent exit if the PTY disconnects during the foreground read', async () => {
const { runtime, batches } = createSideEffectRuntime()
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
@@ -15549,7 +15580,7 @@ describe('OrcaRuntimeService', () => {
})
it('adopts renderer pane identity for remote runtime terminal creates', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg', incarnationId: 'inc-remote-create' })
const runtime = new OrcaRuntimeService(store)
const tabId = 'tab-remote-runtime'
const leafId = '11111111-1111-4111-8111-111111111111'
@@ -15560,7 +15591,7 @@ describe('OrcaRuntimeService', () => {
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
const terminal = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
focus: false,
tabId,
leafId,
@@ -15574,6 +15605,7 @@ describe('OrcaRuntimeService', () => {
(spawn.mock.calls[0]?.[0] as { env?: Record<string, string> } | undefined)?.env ?? {}
expect(spawnedEnv.ORCA_TAB_ID).toBe(tabId)
expect(spawnedEnv.ORCA_PANE_KEY).toBe(`${tabId}:${leafId}`)
expect(terminal.incarnationId).toBe('inc-remote-create')
})
it('does not adopt web mirror ids as host terminal ids', async () => {
+19 -3
View File
@@ -1560,6 +1560,8 @@ type RuntimePtyWorktreeRecord = {
launchAgent: TuiAgent | null
foregroundAgent: TuiAgent | null
connected: boolean
/** Host-certified exit survives connected=false until pending title reads settle. */
hostExitConfirmed: boolean
disconnectedAt: number | null
lastExitCode: number | null
lastExitCause: TerminalExitCause | null
@@ -11259,6 +11261,7 @@ export class OrcaRuntimeService {
this.spawnPublishedPtys.add(ptyId)
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
if (pty) {
pty.hostExitConfirmed = false
if (incarnationId) {
pty.incarnationId = incarnationId
}
@@ -11303,6 +11306,7 @@ export class OrcaRuntimeService {
...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}),
...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {})
})
pty.hostExitConfirmed = false
const agentLaunchAuthority = binding?.agentLaunchAuthority
if (
agentLaunchAuthority &&
@@ -15917,6 +15921,9 @@ export class OrcaRuntimeService {
this.remoteDesktopViewerRevisions.delete(ptyId)
this.disposeHeadlessTerminal(ptyId)
if (pty) {
if (options?.hostExitConfirmed === true) {
pty.hostExitConfirmed = true
}
pty.connected = false
pty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(pty.ptyId, false)
@@ -18953,6 +18960,7 @@ export class OrcaRuntimeService {
tabId: parsed?.tabId ?? record?.tabId ?? '',
leafId: parsed?.leafId ?? record?.leafId ?? '',
ptyId: record?.ptyId ?? null,
...(pty?.incarnationId ? { incarnationId: pty.incarnationId } : {}),
connected: pty?.connected === true,
...(worktreeId ? { worktreeId } : {}),
...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null)
@@ -19003,6 +19011,7 @@ export class OrcaRuntimeService {
tabId: parsed.tabId,
leafId: parsed.leafId,
ptyId: terminal.ptyId ?? null,
...(terminal.incarnationId ? { incarnationId: terminal.incarnationId } : {}),
worktreeId: expectedWorktreeId
}))
this.terminalPaneRecoveryByIdentity.set(recoveryKey, recovery)
@@ -19667,7 +19676,7 @@ export class OrcaRuntimeService {
if (this.getPtyLivenessVerdict(ptyId)?.status === 'unverifiable') {
return true
}
return Boolean(pty?.connectionId && !pty.connected)
return Boolean(pty?.connectionId && !pty.connected && !pty.hostExitConfirmed)
}
private confirmPtyAgentExit(ptyId: string): void {
@@ -29131,6 +29140,7 @@ export class OrcaRuntimeService {
tabId,
paneKey,
ptyId: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
worktreeId: workspace.id,
title: pty?.title ?? launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
@@ -29212,12 +29222,15 @@ export class OrcaRuntimeService {
// populates this.leaves may not have arrived yet. Wait for the leaf to
// appear so we can return a valid handle the caller can use right away.
const handle = await this.waitForTerminalHandle(reply.tabId)
const ptyId = this.handles.get(handle)?.ptyId ?? null
const incarnationId = ptyId ? (this.ptysById.get(ptyId)?.incarnationId ?? null) : null
return {
handle,
tabId: reply.tabId,
worktreeId: worktreeId ?? '',
title: reply.title,
...this.getPtyExecutionHostMetadata(this.handles.get(handle)?.ptyId ?? null),
...(incarnationId ? { incarnationId } : {}),
...this.getPtyExecutionHostMetadata(ptyId),
surface: 'visible'
}
}
@@ -29301,7 +29314,8 @@ export class OrcaRuntimeService {
this.adoptControllerTerminalHandle(session.id, terminalHandle)
const pty = this.recordPtyWorktree(session.id, worktreeId, {
connected: true,
title: session.title
title: session.title,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {})
})
const adoptedHandle = this.issuePtyHandle(pty)
if (adoptedHandle !== terminalHandle) {
@@ -29310,6 +29324,7 @@ export class OrcaRuntimeService {
return {
handle: adoptedHandle,
ptyId: session.id,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
worktreeId,
title: session.title || null,
surface: 'background'
@@ -33182,6 +33197,7 @@ export class OrcaRuntimeService {
launchAgent: null,
foregroundAgent: null,
connected: state.connected ?? true,
hostExitConfirmed: false,
disconnectedAt: state.connected === false ? Date.now() : null,
lastExitCode: null,
lastExitCause: null,
@@ -1,5 +1,6 @@
import type { IDisposable } from '@xterm/xterm'
import type { HasPty } from '../terminal-dead-session-reconcile'
import type { PtyIncarnationId } from '../../../../../shared/pty-incarnation'
export type PanePtyBinding = IDisposable & {
syncProcessTracking: () => void
@@ -27,4 +28,6 @@ export type PanePtyBinding = IDisposable & {
* preserves on exit. Read at unmount so the parked sidecar can carry the
* fact as a plain value instead of a session reference. */
isUntouchedFreshSpawnPty: (ptyId: string) => boolean
/** Captured by parked watchers so confirmed exit facts retain incarnation authority. */
getPtyIncarnationId?: (ptyId: string) => PtyIncarnationId | null
}
@@ -130,12 +130,17 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo
// Do not strand a successful spawn because a delivery callback failed.
}
}
session.onPtyRebind = (ptyId: string, replacedPtyId: string): void => {
session.onPtyRebind = (
ptyId: string,
replacedPtyId: string,
incarnationId?: PtyIncarnationId
): void => {
if (!session.canAdoptCapturedDirectSshRetryPty(ptyId)) {
return
}
// Why: provider handle rotation keeps the existing pane/session generation;
// replace its stale store identity without fresh-spawn exit semantics.
session.ptyIncarnationId = incarnationId ?? null
session.bindActivePanePty(ptyId, { replacePtyId: replacedPtyId })
}
// ─── Attention signal: BEL ────────────────────────────────────────────
@@ -174,6 +174,8 @@ export function installSessionReconcileDispose(session: ConnectPanePtySession):
reconcileIfSessionMissing: session.reconcileIfSessionMissing,
isUntouchedFreshSpawnPty: (ptyId) =>
session.spawnedFreshPtyId === ptyId && !Number.isFinite(session.lastTerminalInputAt),
getPtyIncarnationId: (ptyId) =>
session.transport.getPtyId() === ptyId ? session.ptyIncarnationId : null,
dispose() {
session.disposed = true
// Why: a detached client stops observing the pane's bytes, so it must cede
@@ -258,7 +258,7 @@ export type IpcPtyTransportOptions = {
onTitleChange?: (title: string, rawTitle: string) => void
onPtySpawn?: (ptyId: string, incarnationId?: PtyIncarnationId) => void
/** Rebind an existing pane after its provider replaces the PTY identity. */
onPtyRebind?: (ptyId: string, replacedPtyId: string) => void
onPtyRebind?: (ptyId: string, replacedPtyId: string, incarnationId?: PtyIncarnationId) => void
onBell?: () => void
onAgentBecameIdle?: (title: string) => void
onAgentBecameWorking?: () => void
@@ -93,6 +93,7 @@ describe('createRemoteRuntimePtyTransport', () => {
tabId: 'host-tab-1',
leafId: 'pane:1',
ptyId: 'ssh-private-pty',
incarnationId: 'replacement-incarnation',
worktreeId: 'wt-1'
}
}
@@ -126,7 +127,8 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-replacement' })
expect(onPtyRebind).toHaveBeenCalledWith(
'remote:hub-env@@terminal-replacement',
'remote:hub-env@@terminal-expired'
'remote:hub-env@@terminal-expired',
'replacement-incarnation'
)
expect(onPtyExit).not.toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
@@ -47,6 +47,7 @@ describe('createRemoteRuntimePtyTransport', () => {
tabId: 'tab-1',
leafId,
ptyId: 'ssh:hub-private@@pty-2',
incarnationId: 'hub-incarnation-1',
worktreeId: 'wt-1',
executionHostId: 'ssh:hub-private',
hostPlatform: 'win32'
@@ -57,10 +58,12 @@ describe('createRemoteRuntimePtyTransport', () => {
throw new Error(`Unexpected method ${request.method}`)
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtySpawn = vi.fn()
const transport = createRemoteRuntimePtyTransport('hub-env', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId
leafId,
onPtySpawn
})
const result = await transport.connect({
@@ -73,9 +76,11 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(result).toEqual({
id: 'remote:hub-env@@hub-terminal-1',
incarnationId: 'hub-incarnation-1',
replay: '',
isReattach: true
})
expect(onPtySpawn).toHaveBeenCalledWith('remote:hub-env@@hub-terminal-1', 'hub-incarnation-1')
expect(transport.getPtyId()).toBe('remote:hub-env@@hub-terminal-1')
expect(transport.getExecutionHostId?.()).toBe('ssh:hub-private')
expect(transport.getRemotePlatform?.()).toBe('win32')
@@ -202,6 +202,29 @@ export function createRemoteRuntimePtyTransport(
let attachGeneration = 0
let subscriptionGeneration = 0
function notifyPtySpawnForRemote(
id: string,
incarnationId?: RuntimeTerminalCreate['incarnationId']
): void {
if (incarnationId) {
onPtySpawn?.(id, incarnationId)
} else {
onPtySpawn?.(id)
}
}
function notifyPtyRebindForRemote(
id: string,
replacedId: string,
incarnationId?: RuntimeTerminalResolvePane['incarnationId']
): void {
if (incarnationId) {
onPtyRebind?.(id, replacedId, incarnationId)
} else {
onPtyRebind?.(id, replacedId)
}
}
function setAttachmentReady(ready: boolean): void {
attachmentReady = ready
if (!ready) {
@@ -832,6 +855,7 @@ export function createRemoteRuntimePtyTransport(
return undefined
}
let resolvedIncarnationId: RuntimeTerminalResolvePane['incarnationId']
if (leafId && worktreeId && !resolvePaneUnavailable) {
try {
const resolved = await callRuntime<{ terminal: RuntimeTerminalResolvePane }>(
@@ -846,6 +870,7 @@ export function createRemoteRuntimePtyTransport(
(!terminal.worktreeId || terminal.worktreeId === worktreeId)
) {
adoptExecutionMetadata(terminal)
resolvedIncarnationId = terminal.incarnationId
}
} catch (error) {
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
@@ -866,7 +891,7 @@ export function createRemoteRuntimePtyTransport(
rows: options.rows ?? 24
}
if (notifySpawn) {
onPtySpawn?.(remotePtyId)
notifyPtySpawnForRemote(remotePtyId, resolvedIncarnationId)
}
try {
@@ -882,6 +907,7 @@ export function createRemoteRuntimePtyTransport(
return {
id: remotePtyId,
...(resolvedIncarnationId ? { incarnationId: resolvedIncarnationId } : {}),
replay: '',
isReattach: true
} satisfies PtyConnectResult
@@ -1149,7 +1175,7 @@ export function createRemoteRuntimePtyTransport(
rows: options.rows ?? 24
}
if (notifySpawn) {
onPtySpawn?.(remotePtyId)
notifyPtySpawnForRemote(remotePtyId, terminal.incarnationId)
}
emitRecoveryState()
try {
@@ -1167,7 +1193,12 @@ export function createRemoteRuntimePtyTransport(
) {
return undefined
}
return { id: remotePtyId, replay: '', isReattach: true }
return {
id: remotePtyId,
...(terminal.incarnationId ? { incarnationId: terminal.incarnationId } : {}),
replay: '',
isReattach: true
}
}
function recoverExpiredHostPane(): void {
@@ -1199,7 +1230,7 @@ export function createRemoteRuntimePtyTransport(
if (replacedPtyId && replacedPtyId !== remotePtyId) {
replaceFitOverridePtyId(replacedPtyId, remotePtyId)
replaceDriverPtyId(replacedPtyId, remotePtyId)
onPtyRebind?.(remotePtyId, replacedPtyId)
notifyPtyRebindForRemote(remotePtyId, replacedPtyId, terminal.incarnationId)
}
await subscribeToHandle()
})
@@ -2179,7 +2210,7 @@ export function createRemoteRuntimePtyTransport(
rows: options.rows ?? 24
}
if (createdTerminal.isReattach !== true) {
onPtySpawn?.(remotePtyId)
notifyPtySpawnForRemote(remotePtyId, createdTerminal.incarnationId)
}
emitRecoveryState()
@@ -2196,6 +2227,9 @@ export function createRemoteRuntimePtyTransport(
return {
id: remotePtyId,
...(createdTerminal.incarnationId
? { incarnationId: createdTerminal.incarnationId }
: {}),
replay: '',
...(createdTerminal.isReattach === true ? { isReattach: true } : {})
} satisfies PtyConnectResult
@@ -119,6 +119,7 @@ export function startParkedPtyWatcher(args: {
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
const disposeWatcher = startParkedTerminalByteWatcher({
ptyId,
...(pane.incarnationId ? { incarnationId: pane.incarnationId } : {}),
tabId: tab.id,
worktreeId,
leafId: pane.leafId,
@@ -70,6 +70,9 @@ export function resolveParkedTerminalPaneCandidates(
// Why: the fact belongs to the captured PTY; a re-minted id on this leaf is a different shell.
...(prior.ptyId === pane.ptyId && prior.untouchedFreshSpawn
? { untouchedFreshSpawn: true }
: {}),
...(prior.ptyId === pane.ptyId && prior.incarnationId
? { incarnationId: prior.incarnationId }
: {})
}
: pane
@@ -10,9 +10,12 @@
import { discardPreHandlerPtyState, hasPreHandlerPtyExit } from './pty-pre-handler-buffer'
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { PtyIncarnationId } from '../../../../shared/pty-incarnation'
export type ParkedTerminalPaneCapture = {
ptyId: string | null
/** Process identity carried across the mounted-to-parked handoff. */
incarnationId?: PtyIncarnationId | null
/** PaneManager numeric pane id the live pane used for runtime titles. */
paneId: number
/** Stable terminal-layout leaf UUID (paneKey attribution). */
@@ -1963,10 +1963,17 @@ export function useTerminalPaneLifecycle({
manager.getPanes().map((capturedPane) => {
const capturedPtyId = paneTransports.get(capturedPane.id)?.getPtyId() ?? null
const capturedBinding = panePtyBindings.get(capturedPane.id) as
| (IDisposable & { isUntouchedFreshSpawnPty?: (ptyId: string) => boolean })
| (IDisposable & {
isUntouchedFreshSpawnPty?: (ptyId: string) => boolean
getPtyIncarnationId?: (ptyId: string) => string | null
})
| undefined
return {
ptyId: capturedPtyId,
incarnationId:
capturedPtyId !== null
? (capturedBinding?.getPtyIncarnationId?.(capturedPtyId) ?? null)
: null,
paneId: capturedPane.id,
leafId: capturedPane.leafId,
drivesTabTitle: manager.getActivePane()?.id === capturedPane.id,
+4
View File
@@ -243,6 +243,8 @@ export type RuntimeTerminalCreate = {
tabId?: string
paneKey?: string | null
ptyId?: string | null
/** Opaque process identity behind a reusable PTY id; absence is unknown. */
incarnationId?: PtyIncarnationId
worktreeId: string
title: string | null
executionHostId?: ExecutionHostId
@@ -264,6 +266,8 @@ export type RuntimeTerminalResolvePane = {
tabId: string
leafId: string
ptyId: string | null
/** Opaque process identity behind a reusable PTY id; absence is unknown. */
incarnationId?: PtyIncarnationId
connected?: boolean
worktreeId?: string
executionHostId?: ExecutionHostId