fix: fence terminal session ownership

This commit is contained in:
Jinwoo-H
2026-08-24 23:32:50 -04:00
parent 5b1028232b
commit 211a720fc7
45 changed files with 440 additions and 114 deletions
@@ -19,5 +19,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(33)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(35)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(36)
})
})
@@ -19,7 +19,7 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(36)
expect(PROTOCOL_VERSION).toBe(37)
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35)
expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34)
@@ -33,7 +33,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 35 }, (_, index) => index + 1)
Array.from({ length: 36 }, (_, index) => index + 1)
)
})
@@ -10,7 +10,7 @@ export class DaemonPtyAdapterSubscriptionFanout {
constructor(
private readonly adapters: readonly DaemonPtyAdapter[],
onAdapterExit: (adapter: DaemonPtyAdapter, id: string) => void,
onAdapterExit: (adapter: DaemonPtyAdapter, payload: DaemonPtyRouterExitEvent) => void,
onAdapterIdentityChanged?: (adapter: DaemonPtyAdapter) => void
) {
for (const adapter of adapters) {
@@ -21,7 +21,7 @@ export class DaemonPtyAdapterSubscriptionFanout {
}
}),
adapter.onExit((payload) => {
onAdapterExit(adapter, payload.id)
onAdapterExit(adapter, payload)
for (const listener of this.exitListeners) {
listener(payload)
}
@@ -300,6 +300,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
pendingClaimSpawnOperations: Set<typeof pendingClaim>
sessionIncarnations: Map<string, string>
}
const originalIdentity = internals.lastAuthenticatedIdentity
internals.lastAuthenticatedIdentity = {
...internals.lastAuthenticatedIdentity,
launchNonce: 'replacement-daemon'
@@ -313,6 +314,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(exits).toEqual([])
expect(internals.activeSessionIds.has(sessionId)).toBe(true)
expect(internals.sessionIncarnations.get(sessionId)).toBe(created.incarnationId)
internals.lastAuthenticatedIdentity = originalIdentity
internals.pendingClaimSpawnOperations.delete(pendingClaim)
})
@@ -780,6 +782,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
client: DaemonClient
lastAuthenticatedIdentity: { pid: number; startedAtMs: number; launchNonce: string }
}
const originalIdentity = internals.lastAuthenticatedIdentity
internals.lastAuthenticatedIdentity = {
pid: process.pid,
startedAtMs: Date.now() + 10_000,
@@ -804,6 +807,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(exits).toEqual([])
expect(adapter.hasPty(spawned.id)).toBe(true)
internals.lastAuthenticatedIdentity = originalIdentity
})
})
+3
View File
@@ -433,6 +433,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null {
if (!this.hasExactSessionAuthority(id)) {
return null
}
return this.ownerIdentity(this.sessionIncarnations.get(id)) ?? null
}
+2 -2
View File
@@ -28,8 +28,8 @@ export class DaemonPtyRouter implements IPtyProvider {
this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters)
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(
this.allAdapters(),
(adapter, id) => {
this.ownerResolver.forgetRoute(id, adapter)
(adapter, payload) => {
this.ownerResolver.forgetRoute(payload.id, adapter, payload.incarnationId)
},
(adapter) => this.ownerResolver.invalidateProvider(adapter)
)
+11 -2
View File
@@ -1263,7 +1263,11 @@ export class DaemonServer {
case 'write':
try {
this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now())
this.host.write(request.payload.sessionId, request.payload.data)
this.host.write(
request.payload.sessionId,
request.payload.data,
request.payload.expectedIncarnationId
)
} catch (err) {
this.lastInputAtBySessionId.delete(request.payload.sessionId)
if (err instanceof SessionNotFoundError) {
@@ -1280,7 +1284,12 @@ export class DaemonServer {
case 'resize':
try {
this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows)
this.host.resize(
request.payload.sessionId,
request.payload.cols,
request.payload.rows,
request.payload.expectedIncarnationId
)
} catch (err) {
if (err instanceof SessionNotFoundError) {
this.sendExitEvent(
@@ -9,7 +9,6 @@ it('preserves A topology when compatible replacement B reports A absent', async
sessionIncarnationId: 'session-a',
logicalPtyId: 'logical-pty'
})
const ownerA = Object.freeze({ id: 'daemon-a', reachable: false })
const replacementB = {
listProcesses: vi.fn(async () => []),
probePtyLiveness: vi.fn(async () => false)
@@ -27,11 +26,10 @@ it('preserves A topology when compatible replacement B reports A absent', async
const liveMarkerIo = ['agent-a-still-running']
let freshShells = 0
const verdict = await (
resolver as unknown as {
probe: (id: string, expectedIncarnationId?: string) => Promise<boolean | null>
}
).probe(persistedOwner.logicalPtyId, persistedOwner.sessionIncarnationId)
const verdict = await resolver.probe(
persistedOwner.logicalPtyId,
persistedOwner.sessionIncarnationId
)
if (verdict === false) {
topology.savedRows = []
topology.tabBindings = {}
@@ -39,7 +37,6 @@ it('preserves A topology when compatible replacement B reports A absent', async
freshShells += 1
}
expect(ownerA.reachable).toBe(false)
expect(verdict).toBeNull()
expect(replacementB.probePtyLiveness).not.toHaveBeenCalled()
expect(replacementB.listProcesses).not.toHaveBeenCalled()
@@ -48,6 +45,6 @@ it('preserves A topology when compatible replacement B reports A absent', async
tabBindings: { 'tab-1': 'logical-pty' },
paneBindings: { 'tab-1:leaf-1': 'logical-pty' }
})
expect(liveMarkerIo).toEqual(['agent-a-still-running'])
expect(liveMarkerIo).toHaveLength(1)
expect(freshShells).toBe(0)
})
@@ -40,7 +40,10 @@ export function resolveDaemonSessionOwnerInventory<T extends IPtyProvider>(
(!ownerProviders || ownerProviders.has(exactProvider)) &&
(inventory.complete || expectedIncarnationIsAuthoritative)
? exactProvider
: !expectedIncarnationIsAuthoritative && inventory.complete
: !expectedIncarnationIsAuthoritative &&
inventory.complete &&
(!expectedOwnerIdentity ||
(soleProvider !== undefined && ownerProviders?.has(soleProvider)))
? soleProvider
: undefined
if (!provider) {
@@ -106,7 +106,7 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
}
}
if (routed && this.routes.get(opts.sessionId) === routed) {
this.forgetRoute(opts.sessionId, routed)
this.forgetRoute(opts.sessionId, routed, routedIncarnation)
}
}
}
@@ -170,7 +170,9 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
return null
}
const verdict = routed.probePtyLiveness
? await routed.probePtyLiveness(sessionId, routedIncarnation, expectedOwnerIdentity)
? await (expectedOwnerIdentity === undefined
? routed.probePtyLiveness(sessionId, routedIncarnation)
: routed.probePtyLiveness(sessionId, routedIncarnation, expectedOwnerIdentity))
: (routed.hasPty?.(sessionId) ?? null)
if (
this.routes.get(sessionId) !== routed ||
@@ -224,10 +226,13 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
this.routeIncarnations.set(sessionId, incarnationId)
}
forgetRoute(sessionId: string, provider?: T): void {
forgetRoute(sessionId: string, provider?: T, incarnationId?: string): void {
if (provider && this.routes.get(sessionId) !== provider) {
return
}
if (incarnationId !== undefined && this.routeIncarnations.get(sessionId) !== incarnationId) {
return
}
this.routes.delete(sessionId)
this.routeIncarnations.delete(sessionId)
this.cachedInventory = null
@@ -19,6 +19,7 @@ import {
} from './degraded-daemon-session-routing'
import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing'
import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery'
import type { DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
export class DegradedDaemonPtyProvider implements IPtyProvider {
readonly isDegraded = true
@@ -31,7 +32,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
private ownerRecovery: DegradedDaemonOwnerRecovery
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: PtyDataEvent) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
constructor(opts: {
current: DaemonPtyAdapter
@@ -263,7 +264,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return trackedUnsubscribe
}
onExit(callback: (payload: { id: string; code: number }) => void): () => void {
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
@@ -313,11 +314,12 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
fanoutCurrentDaemonSyntheticExits(code: number): void {
for (const id of this.getCurrentDaemonSessionIds()) {
const incarnationId = this.current.getTerminalOwnerIdentity?.(id)?.sessionIncarnationId
this.sessionProviders.delete(id)
// Why: restart kills listed sessions even when the adapter did not track them active.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({ id, code })
listener({ id, code, ...(incarnationId ? { incarnationId } : {}) })
}
}
}
@@ -112,4 +112,21 @@ describe('TerminalHost incarnation fencing', () => {
expect(initialClient.onData).toHaveBeenCalledWith('still-owned-by-current-client')
expect(staleClient.onData).not.toHaveBeenCalled()
})
it('rejects stale writes and resizes before reaching a replacement session', async () => {
const created = await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(() => host.write('session-1', 'stale', 'old-incarnation')).toThrow()
expect(subprocess.write).not.toHaveBeenCalled()
expect(() => host.resize('session-1', 120, 40, 'old-incarnation')).toThrow()
expect(subprocess.resize).not.toHaveBeenCalled()
host.write('session-1', 'current', created.incarnationId)
host.resize('session-1', 120, 40, created.incarnationId)
expect(subprocess.write).toHaveBeenCalledWith('current')
expect(subprocess.resize).toHaveBeenCalledWith(120, 40)
})
})
+10 -6
View File
@@ -138,16 +138,16 @@ export class TerminalHost {
}
}
write(sessionId: string, data: string): void {
this.getAliveSession(sessionId).write(data)
write(sessionId: string, data: string, expectedIncarnationId?: string): void {
this.getAliveSession(sessionId, expectedIncarnationId).write(data)
}
closeStartupQueryAuthority(sessionId: string): number {
return this.getAliveSession(sessionId).closeStartupQueryAuthority()
}
resize(sessionId: string, cols: number, rows: number): void {
this.getAliveSession(sessionId).resize(cols, rows)
resize(sessionId: string, cols: number, rows: number, expectedIncarnationId?: string): void {
this.getAliveSession(sessionId, expectedIncarnationId).resize(cols, rows)
}
// Why null-not-throw (unlike write/resize): pause/resume are best-effort hints against a session that may have exited.
@@ -310,9 +310,13 @@ export class TerminalHost {
this.killedTombstones.clear()
}
private getAliveSession(sessionId: string): Session {
private getAliveSession(sessionId: string, expectedIncarnationId?: string): Session {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
if (
!session ||
!session.isAlive ||
(expectedIncarnationId !== undefined && session.incarnationId !== expectedIncarnationId)
) {
throw new SessionNotFoundError(sessionId)
}
return session
@@ -394,7 +394,7 @@ describe('registerPtyHandlers', () => {
id: 'pty-claimed-admission',
incarnationId: 'unrelated-incarnation'
})
).toBe(true)
).toBe(false)
await expect(controller.spawn(request)).resolves.toMatchObject({
id: 'pty-claimed-admission',
agentSessionEnsure: { disposition: 'created' }
@@ -8,6 +8,7 @@ import {
setLocalPtyProvider,
unregisterSshPtyProvider
} from './pty'
import { TerminalSessionExitedError } from '../daemon/daemon-errors'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -67,7 +68,7 @@ describe('registerPtyHandlers', () => {
const freshPtyId = `ssh:${connectionId}@@fresh-relay-pty`
const remoteSpawn = vi.fn(async (options: { attachOnly?: boolean; command?: string }) => {
if (options.attachOnly) {
throw new Error('PTY "dead-relay-pty" not found')
throw new TerminalSessionExitedError(deadPtyId)
}
return { id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' }
})
+3 -3
View File
@@ -101,12 +101,12 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
deletePtyOwnership('ssh-detached-pty')
})
it('reports an unconfirmed exact stop instead of a fabricated teardown', async () => {
it('preserves runtime ownership when an exact stop cannot reach its provider', async () => {
setPtyOwnership('ssh-detached-stop', 'ssh-dropped')
const { controller, runtime } = installController()
await expect(controller.stopAndWait('ssh-detached-stop')).resolves.toBe(false)
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-detached-stop', -1, undefined)
expect(runtime.onPtyExit).not.toHaveBeenCalled()
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'ssh-detached-stop',
expect.stringContaining('SSH')
@@ -230,7 +230,7 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
'relay disconnected during kill'
)
)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
expect(runtime.onPtyExit).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(ptyId)
unregisterSshPtyProvider(connectionId)
+40 -20
View File
@@ -315,7 +315,11 @@ const ptyIncarnationById = new Map<string, string>()
export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean {
const current = ptyIncarnationById.get(payload.id)
return payload.incarnationId !== undefined && (!current || payload.incarnationId === current)
// Providers without incarnation support (notably local PTY) remain valid when
// no incarnation was recorded; once one exists, an omitted token is ambiguous.
return current === undefined
? payload.incarnationId === undefined
: payload.incarnationId === current
}
// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation).
const ptySizes = new Map<string, { cols: number; rows: number }>()
@@ -842,13 +846,18 @@ type StablePaneSpawnContext = {
onFreshSpawn?: (result: PtySpawnResult) => void
}
function stablePanePersistenceFence(
owner: StablePaneOwner | null
): { ptyId: string; incarnationId?: string } | undefined {
function stablePanePersistenceFence(owner: StablePaneOwner | null):
| {
ptyId: string
incarnationId?: string
ownerIdentity?: TerminalOwnerIdentity | null
}
| undefined {
return owner?.hasPersistedBinding
? {
ptyId: owner.ptyId,
...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {})
...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}),
ownerIdentity: owner.ownerIdentity ?? null
}
: undefined
}
@@ -2606,6 +2615,11 @@ export function registerPtyHandlers(
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, code, incarnationId, cause ? { cause } : undefined)
sendPtyExitToRenderer({
id,
code,
...(incarnationId ? { incarnationId } : {})
})
},
onData: (id, data, timestamp, sequenceChars, transformed) =>
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
@@ -4136,23 +4150,25 @@ export function registerPtyHandlers(
acceptPtyDataForRenderer(payload, outputSeq)
})
localExitUnsub = localProvider.onExit((payload) => {
// Fence local exits in configure().onExit before cleanup removes their incarnation.
if (isLocalProvider) {
return
}
if (!isCurrentPtyExit(payload)) {
return
}
if (consumeSyntheticKillExit(payload.id)) {
return
}
if (!isLocalProvider) {
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(
payload.id,
payload.code,
payload.incarnationId,
payload.cause ? { cause: payload.cause } : undefined
)
}
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(
payload.id,
payload.code,
payload.incarnationId,
payload.cause ? { cause: payload.cause } : undefined
)
// Why not the whole payload: the exit cause is a main-process fact for the
// runtime's records; the renderer's pty:exit contract stays as it was.
sendPtyExitToRenderer({
@@ -5807,9 +5823,8 @@ export function registerPtyHandlers(
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
} catch {
if (connectionId) {
// Why: the relay lease must still be tombstoned, but an absent SSH
// provider is lost contact — the remote PTY is designed to survive it,
// so nothing here observed an exit to report as a confirmed stop.
// Why: an absent SSH provider is lost contact — the remote PTY is designed
// to survive it, so nothing here observed an exit to report as a confirmed stop.
runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON)
}
return false
@@ -7890,7 +7905,12 @@ export function registerPtyHandlers(
_event,
args: { id: string; paneKey?: string; worktreeId?: string }
): Promise<boolean | null> => {
if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) {
if (
typeof args?.id !== 'string' ||
args.id.length === 0 ||
args.id.length > 512 ||
args.id.startsWith('remote:')
) {
// Why: same routing hazard pty:kill guards against — ptyOwnership never holds
// a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup
// falls through to the local provider and its "not in my table" reads as an
@@ -137,9 +137,11 @@ export function extractSessionForTransfer(
})
)
transferred.terminalPtyOwnersByPaneKey = Object.fromEntries(
Object.entries(source.terminalPtyOwnersByPaneKey ?? {}).filter(([paneKey]) => {
Object.entries(source.terminalPtyOwnersByPaneKey ?? {}).flatMap(([paneKey, owner]) => {
const separator = paneKey.lastIndexOf(':')
return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator))
? [[paneKey, structuredClone(owner)] as const]
: []
})
)
transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries(
@@ -355,6 +355,21 @@ describe('Store', () => {
expect(
store.getWorkspaceSession().terminalPtyOwnersByPaneKey?.[`tab-owner:${TEST_LEAF_1}`]
).toEqual(ownerIdentity)
expect(
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'tab-owner',
leafId: TEST_LEAF_1,
ptyId: 'pty-owner',
incarnationId: 'session-a',
ownerIdentity: { ...ownerIdentity, ownerIncarnationId: 'daemon-b' },
expectedBinding: {
ptyId: 'pty-owner',
incarnationId: 'session-a',
ownerIdentity: { ...ownerIdentity, ownerIncarnationId: 'daemon-b' }
}
})
).toBe(false)
})
it('reconciles only the incarnation of an unchanged durable PTY binding', async () => {
+21 -3
View File
@@ -118,7 +118,10 @@ import {
type RetiredNameRegistry
} from '../../../shared/worktree/retired-name-registry'
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id'
import type { TerminalOwnerIdentity } from '../../../shared/terminal-owner-identity'
import {
sameTerminalOwnerIdentity,
type TerminalOwnerIdentity
} from '../../../shared/terminal-owner-identity'
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
@@ -3230,7 +3233,12 @@ export class Store {
incarnationId?: string
ownerIdentity?: TerminalOwnerIdentity
startupCwd?: string
expectedBinding?: { ptyId: string; incarnationId?: string }
expectedBinding?: {
ptyId: string
incarnationId?: string
/** Present (including null) to compare the persisted owner tuple. */
ownerIdentity?: TerminalOwnerIdentity | null
}
expectedSourceBinding?: PtyBindingSourceExpectation
/** Set by host-initiated creates, which have no renderer session writer behind them. */
hostAdmittedMembership?: boolean
@@ -3266,10 +3274,20 @@ export class Store {
(candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId
)
const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId]
const expectedOwnerMatches =
!('ownerIdentity' in args.expectedBinding) ||
(args.expectedBinding.ownerIdentity === null
? session.terminalPtyOwnersByPaneKey?.[paneKey] === undefined
: sameTerminalOwnerIdentity(
session.terminalPtyOwnersByPaneKey?.[paneKey],
args.expectedBinding.ownerIdentity
))
if (
!tab ||
boundPtyId !== args.expectedBinding.ptyId ||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId
session.terminalPtyIncarnationsByPaneKey?.[paneKey] !==
args.expectedBinding.incarnationId ||
!expectedOwnerMatches
) {
return false
}
@@ -8,7 +8,26 @@ import {
visitPtyProcessListingsInBatches
} from './pty-process-list-admission'
const ownerIdentity = {
executionHostId: 'local' as const,
ownerKind: 'daemon' as const,
ownerIncarnationId: 'daemon-a',
sessionIncarnationId: 'session-a',
protocolVersion: 37
}
describe('PtyProcessListAdmission', () => {
it('retains a validated terminal owner identity', () => {
expect(
new PtyProcessListAdmission().admit({
id: 'pty-1',
cwd: '/repo',
title: 'shell',
ownerIdentity
})
).toMatchObject({ ownerIdentity })
})
it('strips unknown provider payloads from admitted process metadata', () => {
const admission = new PtyProcessListAdmission()
@@ -2,6 +2,7 @@ import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-auth
import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner'
import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import { isTerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
import type { PtyProcessInfo } from './types'
export const MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES = 4096
@@ -35,6 +36,20 @@ function retainedOwnerBytes(owner: unknown, ptyId: string): number | null {
].reduce((total, value) => total + Buffer.byteLength(value, 'utf8'), 0)
}
function retainedTerminalOwnerIdentityBytes(owner: unknown): number | null {
if (!isTerminalOwnerIdentity(owner)) {
return null
}
return [
owner.executionHostId,
owner.ownerKind,
owner.ownerIncarnationId,
owner.sessionIncarnationId,
String(owner.protocolVersion),
...(owner.endpointRef ? [owner.endpointRef] : [])
].reduce((total, value) => total + Buffer.byteLength(value, 'utf8'), 0)
}
export class PtyProcessListAdmission {
private entries = 0
private retainedBytes = 0
@@ -53,6 +68,10 @@ export class PtyProcessListAdmission {
const terminalHandleBytes = retainedOptionalStringBytes(value.terminalHandle)
const wslDistroBytes =
value.wslDistro === null ? 0 : retainedOptionalStringBytes(value.wslDistro)
const terminalOwnerIdentityBytes =
value.ownerIdentity === undefined
? 0
: retainedTerminalOwnerIdentityBytes(value.ownerIdentity)
if (
idBytes === null ||
cwdBytes === null ||
@@ -60,6 +79,7 @@ export class PtyProcessListAdmission {
worktreeIdBytes === null ||
terminalHandleBytes === null ||
wslDistroBytes === null ||
terminalOwnerIdentityBytes === null ||
(value.incarnationId !== undefined && !isPtyIncarnationId(value.incarnationId)) ||
(value.agentSessionOwners !== undefined && !Array.isArray(value.agentSessionOwners))
) {
@@ -91,6 +111,7 @@ export class PtyProcessListAdmission {
worktreeIdBytes +
terminalHandleBytes +
wslDistroBytes +
terminalOwnerIdentityBytes +
ownerBytes
if (
nextEntries > MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES ||
@@ -111,6 +132,7 @@ export class PtyProcessListAdmission {
...(value.worktreeId !== undefined ? { worktreeId: value.worktreeId } : {}),
...(value.terminalHandle !== undefined ? { terminalHandle: value.terminalHandle } : {}),
...(value.wslDistro !== undefined ? { wslDistro: value.wslDistro } : {}),
...(value.ownerIdentity !== undefined ? { ownerIdentity: { ...value.ownerIdentity } } : {}),
...(normalizedOwners !== undefined ? { agentSessionOwners: normalizedOwners } : {})
}
}
@@ -0,0 +1 @@
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
@@ -6,7 +6,7 @@ import {
} from '../../shared/agent-session-host-authority'
import type { PtySpawnResult } from './pty-spawn-result'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation'
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-capability'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
export type ClaimedSshSpawnValidation =
@@ -10,8 +10,8 @@ import {
type PtySourceReceivingActivation
} from '../../shared/pty-source-receiving-activation'
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
export { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-capability'
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-capability'
export function assertSshAgentSessionCreateResult(
result: unknown
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
import { TerminalSessionExitedError } from '../daemon/daemon-errors'
import {
TerminalSessionExitedError,
TerminalSessionOwnerUnverifiedError
} from '../daemon/daemon-errors'
describe('SSH PTY provider session reattach incarnation', () => {
it('remembers the authoritative incarnation before a legacy exit arrives', async () => {
@@ -299,7 +299,13 @@ function rebaseOwnerBindings(
session: WorkspaceSessionState,
prior: WorkspaceSessionState
): WorkspaceSessionState['terminalPtyOwnersByPaneKey'] {
const allowed = new Set(Object.keys(rebaseIncarnationBindings(session, prior) ?? {}))
const retainedIncarnations = rebaseIncarnationBindings(session, prior) ?? {}
const merged = { ...session.terminalPtyOwnersByPaneKey, ...prior.terminalPtyOwnersByPaneKey }
return Object.fromEntries(Object.entries(merged).filter(([paneKey]) => allowed.has(paneKey)))
return Object.fromEntries(
Object.entries(merged).filter(
([paneKey, owner]) =>
retainedIncarnations[paneKey] !== undefined &&
owner.sessionIncarnationId === retainedIncarnations[paneKey]
)
)
}
@@ -59,7 +59,7 @@ describe('startup ordering', () => {
expect(sweepEnd).toBeGreaterThan(sweepStart)
expect(sweep).toContain('const provider = getDaemonProvider()')
expect(sweep).toContain('if (!provider) {')
expect(sweep).toContain('provider.probePtyLiveness(ptyId)')
expect(sweep).toContain('provider.probePtyLiveness(ptyId, expectedIncarnationId)')
})
it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => {
@@ -69,7 +69,7 @@ describe('humanizeTerminalError', () => {
it('replaces the pane-owner-unverified code with actionable copy', () => {
const humanized = humanizeTerminalError('terminal_pane_owner_unverified')
expect(humanized).not.toContain('terminal_pane_owner_unverified')
expect(humanized).toContain('Reopen this pane to retry')
expect(humanized).toContain('left the session and history untouched')
})
it('humanizes an IPC-wrapped pane-owner-unverified error', () => {
@@ -78,6 +78,15 @@ describe('humanizeTerminalError', () => {
expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified')
})
it('hides the owner-conflict diagnostic from user-facing copy', () => {
const humanized = humanizeTerminalError(
"terminal_pane_owner_conflict Error invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified"
)
expect(humanized).not.toContain('terminal_pane_owner_conflict')
expect(humanized).not.toContain('Error invoking remote method')
expect(humanized).toContain('left the session and history untouched')
})
it('leaves other errors untouched', () => {
expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.')
})
@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react'
import { ServerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info'
import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info'
@@ -17,7 +19,13 @@ const STALE_DAEMON_CWD_MARKERS = [
'node-pty: daemon_cwd failed: ENOENT'
]
// Thrown by ipc/pty.ts when a persisted pane owner can't be proven alive or dead (STA-3536).
const PANE_OWNER_UNVERIFIED_MARKER = 'terminal_pane_owner_unverified'
const PANE_OWNER_UNVERIFIED_MARKERS = [
'terminal_pane_owner_unverified',
'terminal_pane_owner_conflict'
]
export function isPaneOwnerUnverifiedError(error: string): boolean {
return PANE_OWNER_UNVERIFIED_MARKERS.some((marker) => error.includes(marker))
}
// Why one source: the test and replace forms must match the same token, and a lone /g regex carries
// lastIndex state across .test() calls. Capture the leading boundary so replacement can restore it.
const TERMINAL_HOST_GONE_SOURCE = '(^|[^a-z0-9_])terminal_host_gone(?=$|[^a-z0-9_])'
@@ -93,16 +101,13 @@ function humanizeUnreattachableSession(error: string): string {
/** Swaps raw daemon-boundary codes for copy a user can act on. */
export function humanizeTerminalError(error: string): string {
let humanized = error
if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) {
humanized = humanized.replace(
PANE_OWNER_UNVERIFIED_MARKER,
translate(
'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db',
"Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry."
)
if (isPaneOwnerUnverifiedError(error)) {
return translate(
'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db',
"Orca couldn't confirm whether this terminal's previous session is still running, so it left the session and history untouched."
)
}
let humanized = error
humanized = humanizeUnreattachableSession(humanized)
if (!isExplainedTerminalError(humanized)) {
return humanized
@@ -128,16 +133,22 @@ export function humanizeTerminalError(error: string): string {
export function TerminalErrorToast({
error,
onDismiss,
onRestartDaemon
onRestartDaemon,
onRetry,
terminalLabel = 'Terminal session'
}: {
error: string
onDismiss: () => void
onRestartDaemon?: () => void
onRetry?: () => void
terminalLabel?: string
}): React.JSX.Element {
const ssh = isSshError(error)
const ownerUnavailable = isPaneOwnerUnverifiedError(error)
const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error)
const showRetry = ownerUnavailable && onRetry !== undefined
// Restart cannot recover a session after its owning daemon exits.
const showIssueLink = !ssh && !showDaemonRestart && !isExplainedTerminalError(error)
const showIssueLink = !ssh && !showDaemonRestart && !showRetry && !isExplainedTerminalError(error)
const displayError = humanizeTerminalError(error)
const [environmentFooter, setEnvironmentFooter] = useState<{
error: string
@@ -162,6 +173,55 @@ export function TerminalErrorToast({
const footer = environmentFooter?.error === displayError ? environmentFooter.footer : ''
if (ownerUnavailable) {
return (
<div
className="pointer-events-none absolute inset-x-3 bottom-3 z-40 flex justify-center"
data-terminal-owner-unavailable-banner
>
<div
className="pointer-events-auto flex w-full max-w-xl items-center gap-3 rounded-md border border-border bg-card px-3 py-3 text-card-foreground shadow-xs"
role="status"
aria-live="polite"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground">
<ServerOff className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-sm font-semibold">
{translate(
'auto.components.terminal.pane.TerminalErrorToast.ownerUnavailableTitle',
'Terminal session unavailable'
)}
</div>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate font-medium">{terminalLabel}</span>
</div>
</div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.terminal.pane.TerminalErrorToast.ownerUnavailableDetail',
"Orca couldn't confirm whether the previous session is still running. The tab and session are preserved; retry when the owner is available."
)}
</div>
{footer ? (
<div className="mt-1 text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]">
{footer}
</div>
) : null}
</div>
<Button className="shrink-0" size="sm" onClick={onRetry}>
{translate(
'auto.components.terminal.pane.TerminalErrorToast.retryConnection',
'Retry connection'
)}
</Button>
</div>
</div>
)
}
return (
<div
style={{
@@ -172,9 +232,12 @@ export function TerminalErrorToast({
zIndex: 50,
padding: '10px 14px',
borderRadius: 6,
background: ssh ? 'rgba(234, 179, 8, 0.12)' : 'rgba(220, 38, 38, 0.15)',
border: ssh ? '1px solid rgba(234, 179, 8, 0.35)' : '1px solid rgba(220, 38, 38, 0.4)',
color: ssh ? '#fde68a' : '#fca5a5',
background: ssh || ownerUnavailable ? 'rgba(234, 179, 8, 0.12)' : 'rgba(220, 38, 38, 0.15)',
border:
ssh || ownerUnavailable
? '1px solid rgba(234, 179, 8, 0.35)'
: '1px solid rgba(220, 38, 38, 0.4)',
color: ssh || ownerUnavailable ? '#fde68a' : '#fca5a5',
fontSize: 12,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
@@ -235,12 +298,34 @@ export function TerminalErrorToast({
)}
</button>
) : null}
{showRetry ? (
<button
onClick={onRetry}
style={{
marginLeft: 12,
border: '1px solid rgba(252, 165, 165, 0.45)',
borderRadius: 6,
background: 'rgba(127, 29, 29, 0.35)',
color: '#fecaca',
cursor: 'pointer',
fontSize: 12,
padding: '4px 8px',
whiteSpace: 'nowrap',
flexShrink: 0
}}
>
{translate(
'auto.components.terminal.pane.TerminalErrorToast.retryConnection',
'Retry connection'
)}
</button>
) : null}
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: ssh ? '#fde68a' : '#fca5a5',
color: ssh || ownerUnavailable ? '#fde68a' : '#fca5a5',
cursor: 'pointer',
fontSize: 14,
padding: '0 0 0 8px',
@@ -59,7 +59,12 @@ import { resolveLeafCloseCopyKind } from '../terminal/terminal-close-copy-kind'
import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from '../terminal/running-terminal-close-guard'
import CodexRestartChip from '../CodexRestartChip'
import { MobileDriverOverlay } from './MobileDriverOverlay'
import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast'
import {
isPaneOwnerUnverifiedError,
stripSshReconnectOwnedErrorLines,
TerminalErrorToast
} from './TerminalErrorToast'
import { TERMINAL_PANE_OWNER_UNVERIFIED } from '../../../../shared/terminal-pane-owner-verdict'
import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
import TerminalContextMenu from './TerminalContextMenu'
@@ -501,6 +506,10 @@ function TerminalPane(
transport.notifyErrorSurfaceDismissed?.()
}
}, [])
const retryTerminalConnection = useCallback(() => {
setTerminalError(null)
useAppStore.getState().remountTerminalTabForRecovery(tabId)
}, [tabId])
const onPtyRecoveryStateRef = useRef(
(paneId: number, state: PtyTransportRecoveryState | null) => {
setPtyRecoveryStatesByPaneId((previous) =>
@@ -2923,6 +2932,32 @@ function TerminalPane(
const activePane = managerRef.current?.getActivePane()
const managedPanes = managerRef.current?.getPanes() ?? []
// A daemon handoff can leave the renderer waiting on a preserved PTY without
// producing an IPC error. Surface the same explicit retry state instead of
// leaving the user with an unexplained blank pane.
useEffect(() => {
const currentPanes = managerRef.current?.getPanes() ?? []
if (!isActive || !isVisible || currentPanes.length === 0) {
return
}
const savedPtyIds = Object.values(savedLayout.ptyIdsByLeafId ?? {}).filter(Boolean)
if (savedPtyIds.length === 0) {
return
}
const hasUnboundPane = currentPanes.some(
(pane) => paneTransportsRef.current.get(pane.id)?.getPtyId() == null
)
if (!hasUnboundPane) {
if (terminalError && isPaneOwnerUnverifiedError(terminalError)) {
setTerminalError(null)
}
return
}
const timer = window.setTimeout(() => {
setTerminalError((current) => current ?? TERMINAL_PANE_OWNER_UNVERIFIED)
}, 1_500)
return () => window.clearTimeout(timer)
}, [isActive, isVisible, managerRef, paneCount, paneLayoutRevision, savedLayout, terminalError])
const showSshReconnectOverlay = Boolean(
isActive &&
isVisible &&
@@ -3076,6 +3111,8 @@ function TerminalPane(
<TerminalErrorToast
error={terminalError}
onDismiss={dismissTerminalError}
onRetry={isPaneOwnerUnverifiedError(terminalError) ? retryTerminalConnection : undefined}
terminalLabel={terminalTab?.title ?? 'Terminal session'}
onRestartDaemon={() => daemonActions.setPending('restart')}
/>
) : null}
@@ -741,17 +741,14 @@ describe('connectPanePty', () => {
1,
expect.objectContaining({ sessionId: staleSessionId })
)
expect(transport.connect).toHaveBeenNthCalledWith(
2,
expect.not.objectContaining({ sessionId: expect.any(String) })
)
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', staleSessionId)
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.clearTabPtyId).not.toHaveBeenCalledWith('tab-1', staleSessionId)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('--- session restored ---'),
expect.any(Function)
)
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored')
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled()
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
})
})
@@ -424,10 +424,8 @@ describe('connectPanePty', () => {
})
})
it('applies the fresh-shell reset when a spawn is answered with a cold-restore reattach', async () => {
// Why: main can answer a *spawn* with an adopted session, so the reattach handler
// is reachable by a second door that skips the restored-session path entirely. The
// cold-restore signal has to survive that door too, or #12101 returns on it.
it('does not spawn a replacement shell when reattach ownership is unverifiable', async () => {
// Unknown ownership must not turn an adopted pane into a fresh shell.
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
let activePtyId = 'tab-pty'
@@ -464,7 +462,7 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(30)
expect(transport.connect).toHaveBeenCalledTimes(2)
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(transport.connect.mock.calls[0]?.[0]?.sessionId).toBe('tab-pty')
expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined()
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
@@ -472,10 +470,8 @@ describe('connectPanePty', () => {
)
const output = writes.join('')
const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ')
const resetIndex = output.indexOf(POST_REPLAY_MODE_RESET)
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
expect(resetIndex).toBeGreaterThan(snapshotIndex)
expect(writes).toContain(POST_REPLAY_MODE_RESET)
expect(snapshotIndex).toBe(-1)
expect(writes).not.toContain(POST_REPLAY_MODE_RESET)
expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)
})
})
@@ -438,7 +438,7 @@ describe('connectPanePty', () => {
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
await flushAsyncTicks(20)
expect(transport.connect).toHaveBeenCalledTimes(2)
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled()
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
@@ -74,7 +74,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
}
if (connectResult?.exitedBeforeAttach) {
// Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
// Ordinary terminal exits are final; do not replace them with a new shell.
return true
}
@@ -1,14 +1,25 @@
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
import { TERMINAL_PANE_OWNER_UNVERIFIED } from '../../../../../shared/terminal-pane-owner-verdict'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function recoverUnverifiableReattach(
session: ConnectPanePtySession,
ptyId: string | null | undefined
): void {
const reportOwnerUnverifiable = (): void => {
session.deps?.onPtyErrorRef?.current?.(session.pane?.id ?? 0, TERMINAL_PANE_OWNER_UNVERIFIED)
}
if (session.directSshRetryAttempt) {
reportOwnerUnverifiable()
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
return
}
// Keep the pane mounted so the user gets an explicit retry action. An
// automatic remount made an owner-routing failure look like a blank pane.
if (session.deps?.onPtyErrorRef?.current) {
reportOwnerUnverifiable()
return
}
void requestTerminalPaneRecovery({
tabId: session.deps.tabId,
ptyId: ptyId ?? null,
@@ -123,6 +123,32 @@ describe('createIpcPtyTransport', () => {
}
)
it('preserves a named TerminalSessionExitedError before IPC message extraction', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const error = new Error('transport closed')
error.name = 'TerminalSessionExitedError'
const spawnMock = vi.fn().mockRejectedValue(error)
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
spawn: spawnMock,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {})
}
}
} as unknown as typeof window
await expect(
createIpcPtyTransport().connect({ url: '', sessionId: 'pty-dead', callbacks: {} })
).resolves.toEqual({ id: 'pty-dead', exitedBeforeAttach: true })
})
it('does not reinterpret an unexpected reattach failure as an ownership verdict', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawnMock = vi.fn().mockRejectedValue(new Error('unexpected provider failure'))
@@ -965,7 +965,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return spawnResult.id
} catch (err) {
const msg = extractIpcErrorMessage(err, err instanceof Error ? err.message : String(err))
if (options.sessionId && isTerminalSessionExited(msg)) {
if (options.sessionId && (isTerminalSessionExited(err) || isTerminalSessionExited(msg))) {
return {
id: options.sessionId,
exitedBeforeAttach: true
+4 -1
View File
@@ -2930,7 +2930,10 @@
"a7e2fd2699": "file an issue",
"5c8ce20be6": "If this persists, please",
"cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.",
"7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry.",
"7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session and history untouched.",
"retryConnection": "Retry connection",
"ownerUnavailableTitle": "Terminal session unavailable",
"ownerUnavailableDetail": "Orca couldn't confirm whether the previous session is still running. The tab and session are preserved; retry when the owner is available.",
"e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.",
"sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue."
},
@@ -3,9 +3,9 @@
"appId": "com.stablyai.orca",
"stateSchemaVersion": 1,
"readableStateSchemaVersions": [1],
"daemonProtocolVersion": 36,
"daemonProtocolVersion": 37,
"attachableDaemonProtocolVersions": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35, 36
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37
]
}
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
appId: 'com.stablyai.orca',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
daemonProtocolVersion: 36,
daemonProtocolVersion: 37,
attachableDaemonProtocolVersions: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35, 36
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37
]
} as const
@@ -0,0 +1,8 @@
import { z } from 'zod'
import { terminalOwnerIdentitySchema } from './terminal-owner-identity-schema'
import { salvagedOptional, salvagingRecord } from './zod-salvage'
export const terminalPtyOwnersByPaneKeySchema = salvagedOptional(
'terminalPtyOwnersByPaneKey',
salvagingRecord(z.string(), terminalOwnerIdentitySchema)
)
@@ -19,6 +19,7 @@ describe('terminal owner identity', () => {
expect(isTerminalOwnerIdentity(identity)).toBe(true)
expect(isTerminalOwnerIdentity({ ...identity, ownerIncarnationId: '' })).toBe(false)
expect(isTerminalOwnerIdentity({ ...identity, protocolVersion: 0 })).toBe(false)
expect(isTerminalOwnerIdentity({ ...identity, ownerKind: 'unknown' })).toBe(false)
})
it('does not treat a replacement owner as the same authority', () => {
+6 -1
View File
@@ -33,7 +33,12 @@ export function isTerminalOwnerIdentity(value: unknown): value is TerminalOwnerI
return (
typeof candidate.executionHostId === 'string' &&
normalizeExecutionHostId(candidate.executionHostId) !== null &&
typeof candidate.ownerKind === 'string' &&
(candidate.ownerKind === 'daemon' ||
candidate.ownerKind === 'ssh' ||
candidate.ownerKind === 'wsl' ||
candidate.ownerKind === 'relay' ||
candidate.ownerKind === 'paired-runtime' ||
candidate.ownerKind === 'local-direct') &&
typeof candidate.ownerIncarnationId === 'string' &&
candidate.ownerIncarnationId.length > 0 &&
typeof candidate.sessionIncarnationId === 'string' &&
+2 -5
View File
@@ -27,7 +27,7 @@ import {
browserWorkspaceSchema
} from './workspace-session-browser-schema'
import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents'
import { terminalOwnerIdentitySchema } from './terminal-owner-identity-schema'
import { terminalPtyOwnersByPaneKeySchema } from './terminal-owner-bindings-schema'
import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage'
// ─── Terminal pane layout (recursive) ───────────────────────────────
@@ -311,10 +311,7 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
'terminalPtyIncarnationsByPaneKey',
salvagingRecord(z.string(), z.string().min(1).max(128))
),
terminalPtyOwnersByPaneKey: salvagedOptional(
'terminalPtyOwnersByPaneKey',
salvagingRecord(z.string(), terminalOwnerIdentitySchema)
),
terminalPtyOwnersByPaneKey: terminalPtyOwnersByPaneKeySchema,
terminalTopologyRevisionByRepoId: salvagedOptional(
'terminalTopologyRevisionByRepoId',
salvagingRecord(z.string(), z.number().int().nonnegative())