fix(pty): require proven absence before retiring a stable pane owner (#12393)

* fix(pty): require proven absence before retiring a stable pane owner

"Session not found" only proves the provider we asked has no such PTY. A
degraded router answers unmapped session ids from the local fallback, which
never owned a daemon session, so a live agent's PTY produced the same error a
dead one does — and the pane was then given a synthetic exit, its durable
pane->PTY binding deleted, and a duplicate spawned. For a single-pane tab the
retirement also drops the tab from persisted state, which the renderer cannot
add back.

Gate the retirement on probePtyLiveness, which polls every possible owner and
answers false only when all of them agree the session is absent. `null` (nobody
could answer) is not absence. Providers without a probe are their own sole
owner, so their refusal stays authoritative and their behavior is unchanged.

Also move the retirement's durability barrier off writeToDiskSync: it fsyncs
the whole multi-MB state from the main thread, and a restore that retired N
dead panes paid that stall N times.

* fix(pty): make the unproven-absence veto transient and route attaches to the real owner

Review found the proven-absence gate correct in direction but terminal in
effect: "probe says alive" and "probe says unknown" both became a hard spawn
failure with no consumer, no retry, and a raw internal token in a toast.

- Graceful teardown (should-fix): a session being killed reports alive
  (getAppliedSize answers while isAlive, and isTerminating does not flip
  _state) while createOrAttach already throws SessionNotFoundError, so for up
  to KILL_TIMEOUT_MS the pane failed instead of retiring the doomed binding.
  The veto now re-proves on a bounded 6s budget: the moment any owner answers
  "absent" the pane retires and spawns fresh exactly as before.

- Unprovable owners (should-fix): DaemonPtyAdapter.probePtyLiveness never
  called ensureConnected, so a merely disconnected adapter answered null
  forever and one null poisons probePtyOwners; it also took no deadline, so a
  wedged daemon burned the client's 30s request timeout per attach. It now
  connects first (like listProcesses) and threads an absolute deadline through
  the fan-out. The thrown error is user-legible English instead of
  terminal_pane_owner_unverified.

- Durability barrier (should-fix): flushPendingOrThrowAsync is not a twin of
  flushOrThrow — it defaults to the drain-to-stable-generation loop the
  sibling best-effort caller deliberately avoids, adds active-view and GitHub
  sidecar writes, re-serializes state per iteration, and rejects when writes
  are frozen where the sync path no-ops. Reverted to flushOrThrow, matching
  the sibling retire paths in orca-runtime.

- Misrouting root cause (nit): DegradedDaemonFreshSpawnRouter.spawn sent an
  unmapped sessionId to the local fallback and DaemonPtyRouter.spawn sent it to
  the current daemon, both faking "Session not found" for a live session. Both
  now resolve an existing owner first (the same resolution every non-spawn path
  already uses); minted ids still route to the fallback/current daemon. The
  pane reattaches instead of only surviving.

Tests: retry-until-proven, teardown-window retire, misroute-then-reattach,
probe deadline threading, unconnected-adapter probe, and both routers' owner
resolution. Each fails with only the source reverted.

* fix(pty): drop the follow-up, keep the minimal proven-absence guard

Reverts aa27eb5274 and leaves only the original guard: retire a stable pane's
owner solely when a provider authoritatively answers absent
(probePtyLiveness === false). `true` and `null` still refuse to authorize
destruction, and providers without a probe keep their refusal authoritative, so
their behavior is unchanged.

The follow-up bought too much surface for a P0, and two of its pieces did not
hold up:

- The `hasPty` owner scan added to DaemonPtyRouter.spawn and the degraded
  fresh-spawn router is inert for the case it targeted. `hasPty` reads
  `activeSessionIds`, which only spawn/attach populate — `listProcesses`
  (daemon-pty-adapter.ts) never does. After a failed discovery the scan
  therefore cannot find the owner it was written to find.
- Its two routing tests could not fail: the mocks backed `hasPty` and
  `listProcesses` with one shared array, giving the fake adapter knowledge the
  real DaemonPtyAdapter provably lacks.

Also dropped: the bounded 6s retry loop and its constants, the sentinel symbol,
the exported user-facing message, the `opts.deadlineMs` widening of
probePtyLiveness and its threading through the probe/routers, and the
ensureConnected change in DaemonPtyAdapter.

Kept from the follow-up: the flushOrThrow revert. flushPendingOrThrowAsync is
not a like-for-like twin (drain-to-stable-generation loop, sidecar writes,
per-iteration serialize, rejects when writes are frozen), so
retirePersistedStablePaneOwner stays synchronous.

One gap the strict one-shot gate does open is closed here. `getSize` landed in
daemon protocol v18, and legacy adapters are created for every version in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS (1..30). A pre-v18 daemon error-replies
"Unknown request type", probePtyLiveness catches it as `null`, and one `null`
makes probePtyOwners unprovable forever — so a genuinely dead pane could never
retire. probePtyLiveness now version-gates getSize and asks such a daemon for
`listSessions` instead, the same inventory legacy discovery already routes by,
requested directly rather than through listProcesses (which swallows errors into
an empty list) so an unreachable socket still answers `null`. Unreachable never
means absent; only an owner that answered does.

* fix(daemon): detect getSize support instead of inferring it from the protocol number

The version gate was not a sound boundary. `getSize` landed in 22b00a7cd2 while
PROTOCOL_VERSION was already 18 and that commit did not bump it, so version 18
means two different things: daemons built before it lack the request, daemons
built after it have it. Both report 18.

A daemon from that window therefore passed the `>= 18` gate, was sent `getSize`,
error-replied "Unknown request type", and answered `null` — and one `null` makes
the owner fan-out permanently unprovable, so a genuinely dead pane could never
retire. That is the same wedge the gate was added to close, just narrowed to one
build window.

Ask the daemon what it supports rather than trusting its number: attempt
`getSize`, and fall back to `listSessions` only when the daemon itself replies
that the request type is unknown. The predicate is deliberately narrow so a
transient failure stays unproven instead of being mistaken for a missing
capability, and the rejection is remembered so later probes skip a round trip
that cannot work. This mirrors the capability-probe rule AGENTS.md already
requires for Git, for the same reason: version numbers overstate capabilities.

Unreachable still never means absent.

* test(pty): pin that proven absence still retires, and correct a wrong rationale

Review found the guard had no positive-direction coverage: every existing retire
test uses a provider with no probePtyLiveness, so the guard is skipped there.
Mutating `!== false` into `!== undefined` — which turns the guard into a
permanent veto, so no daemon-backed pane could ever recover from a genuinely
dead owner — left the whole suite green. Pinned with a case that fails under
exactly that mutation.

Also corrects the fallback's Why-comment. It claimed listProcesses "swallows
errors into an empty list"; it does not — DaemonPtyAdapter.listProcesses
rethrows and the router fails closed. The error-swallowing lives in
discoverLegacySessions / discoverDegradedDaemonSessions. Requesting listSessions
directly is still right, but because a liveness probe should not publish
inventory audit observations as a side effect — not for the stated reason.

* fix(daemon): bound the liveness probe so a wedged daemon cannot stall a pane mount

probePtyLiveness passed no timeout, so each request fell back to the client's
30s REQUEST_TIMEOUT_MS. A wedged daemon holds its socket open rather than
refusing, so it answers neither quickly nor at all — and probePtyOwners awaits
every adapter before it can conclude anything. One hung daemon therefore stalled
each restoring pane for up to 30s before the pane reported that it could not
reopen.

Nearly all local terminals are daemon-backed, so this is the common
configuration, not an edge case.

Bound both probe requests to 2s. Answering "unknown" quickly is strictly better
here than answering slowly: unknown never authorizes retirement, it only defers
it, so the only thing a shorter budget can cost is an earlier retry. The tests
now pin the bound at the call rather than leaving it implicit.

* test(daemon): pin the getSize probe timeout, and reattach an orphaned comment

Review proved a coverage gap by mutation: removing LIVENESS_PROBE_TIMEOUT_MS
from the getSize request left the whole suite green, so nothing stopped that leg
regressing to the client's 30s default. Only the listSessions leg was pinned.
The assertion now fails under exactly that mutation.

Also moves isUnknownRequestTypeError below isDaemonGoneError. It had been
inserted between isDaemonGoneError and the comment describing it, so that
comment read as documenting the wrong function.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-08-03 23:48:18 -07:00
committed by GitHub
co-authored by OrcaWin
parent d8e5944b60
commit ac7f9a4fe1
5 changed files with 412 additions and 5 deletions
@@ -6,6 +6,8 @@ export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31
export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
// Why: `getSize` landed in v18; older daemons reject it as an unknown request type.
export const GET_SIZE_PROTOCOL_VERSION = 18
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26
export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26
+99 -1
View File
@@ -5,10 +5,11 @@ import { join } from 'node:path'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { DaemonClient } from './client'
import { DaemonProtocolError } from './daemon-errors'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter'
import {
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GET_SIZE_PROTOCOL_VERSION,
PROTOCOL_VERSION
} from './daemon-protocol-version'
import { DaemonServer } from './daemon-server'
@@ -1204,6 +1205,103 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await expect(adapter.probePtyLiveness('session')).resolves.toBeNull()
})
function createProbeAdapter(
protocolVersion: number,
request: ReturnType<typeof vi.fn>
): DaemonPtyAdapter {
const probeAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion })
;(
probeAdapter as unknown as {
client: { request: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }
}
).client = { request, disconnect: vi.fn() }
return probeAdapter
}
// Why: a pre-v18 daemon rejects `getSize` as an unknown request type, so it would answer
// `null` forever — and one `null` makes the owner fan-out permanently unprovable, which
// would leave a genuinely dead pane unable to ever retire and respawn.
it('answers a pre-getSize daemon from its session inventory', async () => {
// Faithful pre-v18 daemon: routeRequest falls through its switch and error-replies.
const request = vi.fn(async (type: string) => {
if (type !== 'listSessions') {
throw new Error(`Unknown request type: ${type}`)
}
return {
sessions: [
{ sessionId: 'legacy-live', isAlive: true },
{ sessionId: 'legacy-exited', isAlive: false }
]
}
})
const legacy = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION - 1, request)
await expect(legacy.probePtyLiveness('legacy-live')).resolves.toBe(true)
await expect(legacy.probePtyLiveness('legacy-exited')).resolves.toBe(false)
await expect(legacy.probePtyLiveness('never-existed')).resolves.toBe(false)
expect(request).toHaveBeenCalledWith('listSessions', undefined, LIVENESS_PROBE_TIMEOUT_MS)
expect(request).not.toHaveBeenCalledWith('getSize', expect.anything())
legacy.dispose()
})
// The P0 boundary: an owner that cannot be reached must never read as one that answered "absent".
it('still answers unknown when a pre-getSize daemon cannot be reached', async () => {
const request = vi.fn(async () => {
throw new Error('Not connected')
})
const legacy = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION - 1, request)
await expect(legacy.probePtyLiveness('legacy-live')).resolves.toBeNull()
legacy.dispose()
})
// Why: `getSize` shipped into an already-released protocol without a version bump, so a
// daemon can report a version that implies support and still reject the request. Gating on
// the number alone left those daemons permanently unprovable — the same wedge, narrowed.
it('falls back when a daemon rejects getSize despite reporting a version that has it', async () => {
const request = vi.fn(async (type: string) => {
if (type === 'getSize') {
throw new Error(`Unknown request type: ${type}`)
}
return { sessions: [{ sessionId: 'ambiguous-live', isAlive: true }] }
})
const ambiguous = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION, request)
await expect(ambiguous.probePtyLiveness('ambiguous-live')).resolves.toBe(true)
await expect(ambiguous.probePtyLiveness('never-existed')).resolves.toBe(false)
// The rejection is remembered, so later probes skip the round trip that cannot work.
expect(request.mock.calls.filter(([type]) => type === 'getSize')).toHaveLength(1)
// Why pinned here: a wedged daemon holds its socket open, so an unbounded getSize would
// stall a pane mount for the client's 30s default instead of answering "unknown" in 2s.
expect(request).toHaveBeenCalledWith(
'getSize',
{ sessionId: 'ambiguous-live' },
LIVENESS_PROBE_TIMEOUT_MS
)
ambiguous.dispose()
})
// The safety direction: only the daemon's own "I do not implement that" may switch strategy.
// A transient failure must stay unproven rather than be retried as a capability question.
it('keeps a transient getSize failure unproven instead of treating it as unsupported', async () => {
const request = vi.fn(async (type: string) => {
if (type === 'getSize') {
throw new Error('Connection lost')
}
return { sessions: [] }
})
const flaky = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION, request)
await expect(flaky.probePtyLiveness('live-elsewhere')).resolves.toBeNull()
expect(request).not.toHaveBeenCalledWith('listSessions', undefined, LIVENESS_PROBE_TIMEOUT_MS)
flaky.dispose()
})
})
describe('getBufferSnapshot', () => {
+49 -4
View File
@@ -37,6 +37,7 @@ import {
type TakePendingOutputResult
} from './types'
import {
GET_SIZE_PROTOCOL_VERSION,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION,
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
@@ -139,6 +140,12 @@ export type DaemonIdentityChangeEvent = {
const MAX_TOMBSTONES = 1000
const MAX_CONCURRENT_CHECKPOINTS = 4
// Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded
// probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every
// adapter, so one hung daemon stalls each restoring pane. Answering "unknown" quickly is strictly
// better here: unknown never authorizes retirement, it only defers it.
export const LIVENESS_PROBE_TIMEOUT_MS = 2_000
// Why: providers take an absolute teardown deadline, but the client RPC takes a
// relative timeout — convert only here, at the request itself, so sequential RPCs
// naturally share the remaining budget (undefined keeps the client's 30s default).
@@ -203,6 +210,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.sleepRestoreSessionIds.delete(sessionId)
})
private activeSessionIds = new Set<string>()
// Set only once this daemon has rejected `getSize` as unknown; its protocol number cannot prove it.
private getSizeUnsupported = false
// A replacement daemon has none of the old PTYs; only createOrAttach can make their bindings writable again.
private sessionsAwaitingDaemonRecovery = new Set<string>()
private sessionIncarnations = new Map<string, string>()
@@ -900,11 +909,37 @@ export class DaemonPtyAdapter implements IPtyProvider {
async probePtyLiveness(id: string): Promise<boolean | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id }
if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id },
LIVENESS_PROBE_TIMEOUT_MS
)
return result.size !== null
} catch (error) {
// Why the capability probe rather than the version alone: `getSize` shipped into an
// already-released protocol without a bump, so a daemon can report a version that
// implies support and still reject the request. Ask what it can do, not what its
// number implies — and remember the answer so later probes skip the dead round trip.
if (!isUnknownRequestTypeError(error)) {
throw error
}
this.getSizeUnsupported = true
}
}
// Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null`
// makes the whole owner fan-out unprovable — a dead pane could then never be retired.
// `listSessions` is the same inventory legacy discovery routes by, and has existed since
// the first daemon protocol. Requested directly rather than through `listProcesses` so a
// liveness probe does not publish inventory audit observations as a side effect; both
// rethrow on failure, so either way a dead socket stays `null` instead of reading absent.
const { sessions } = await this.client.request<ListSessionsResult>(
'listSessions',
undefined,
LIVENESS_PROBE_TIMEOUT_MS
)
return result.size !== null
return sessions.some((session) => session.sessionId === id && session.isAlive)
} catch {
return null
}
@@ -2409,6 +2444,16 @@ function notifyAuditListeners<T>(listeners: readonly ((value: T) => void)[], val
}
}
/**
* Narrow on purpose: only the daemon's own reply for a request type it does not implement.
* A transient failure must stay unproven rather than be mistaken for a missing capability.
* The server throws `Unknown request type: <type>`; the client rejects with that text, which
* `addNodePtyRecoveryHint` only ever prepends to.
*/
function isUnknownRequestTypeError(err: unknown): boolean {
return err instanceof Error && err.message.includes('Unknown request type')
}
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
function isDaemonGoneError(err: unknown): boolean {
+253
View File
@@ -9108,6 +9108,259 @@ describe('registerPtyHandlers', () => {
}
)
it.each([
{ label: 'another owner reports it alive', liveness: true },
{ label: 'no owner could answer', liveness: null }
])('keeps a persisted owner whose absence is unproven ($label)', async ({ liveness }) => {
const worktreeId = 'repo-1::/tmp/unproven-owner'
const cwd = '/tmp/unproven-owner'
const tabId = 'tab-unproven-owner'
const leafId = '56565656-5656-4656-8656-565656565656'
const paneKey = makePaneKey(tabId, leafId)
// Why: a degraded router answers unmapped ids from the local fallback, which never
// owned this daemon session — the same "Session not found" a truly dead PTY yields.
const providerSpawn = vi.fn(
async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => {
if (options.attachOnly) {
throw new Error('Session not found: pty-unproven-owner')
}
return { id: 'pty-fresh-unproven', incarnationId: 'inc-fresh-unproven' }
}
)
const probePtyLiveness = vi.fn(async () => liveness)
setLocalPtyProvider({
spawn: providerSpawn,
probePtyLiveness,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
let session = {
tabsByWorktree: {
[worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-unproven-owner' }]
},
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-unproven-owner' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-unproven-owner' }
}
const store = {
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
}),
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn(),
getFolderWorkspace: vi.fn(() => undefined),
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
throw new Error('terminal_not_found')
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term-unproven'),
preAllocateHandleForPty: vi.fn(() => 'term-unproven'),
registerPreAllocatedHandleForPty: vi.fn(),
beginPtyRegistration: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
assertPtyRegistrationAllowed: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedHeadlessTerminal: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd,
command: 'codex resume unproven-owner-session',
worktreeId,
tabId,
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
})
).rejects.toThrow('terminal_pane_owner_unverified')
expect(probePtyLiveness).toHaveBeenCalledWith('pty-unproven-owner')
// The live PTY keeps its pane binding, gets no synthetic exit, and is not duplicated.
expect(providerSpawn).toHaveBeenCalledOnce()
expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ attachOnly: true })
expect(runtime.onPtyExit).not.toHaveBeenCalled()
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
expect(store.flushOrThrow).not.toHaveBeenCalled()
expect(session.tabsByWorktree[worktreeId]).toHaveLength(1)
})
// Why the positive direction needs its own case: the sibling retire tests use providers with
// no `probePtyLiveness`, so they skip this guard entirely. Without this, the guard could be
// strengthened into a permanent veto — no daemon-backed pane could ever recover from a dead
// owner — and every suite would stay green.
it('still retires and respawns when a provider proves the owner is absent', async () => {
const worktreeId = 'repo-1::/tmp/proven-absent-owner'
const cwd = '/tmp/proven-absent-owner'
const tabId = 'tab-proven-absent-owner'
const leafId = '78787878-7878-4878-8878-787878787878'
const paneKey = makePaneKey(tabId, leafId)
const providerSpawn = vi.fn(
async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => {
if (options.attachOnly) {
throw new Error('Session not found: pty-proven-absent-owner')
}
return { id: 'pty-fresh-proven', incarnationId: 'inc-fresh-proven' }
}
)
const probePtyLiveness = vi.fn(async () => false)
setLocalPtyProvider({
spawn: providerSpawn,
probePtyLiveness,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
let session = {
tabsByWorktree: {
[worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-proven-absent-owner' }]
},
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-proven-absent-owner' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-proven-absent-owner' }
}
const store = {
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
}),
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn(),
getFolderWorkspace: vi.fn(() => undefined),
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
throw new Error('terminal_not_found')
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term-proven-absent'),
preAllocateHandleForPty: vi.fn(() => 'term-proven-absent'),
registerPreAllocatedHandleForPty: vi.fn(),
beginPtyRegistration: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
assertPtyRegistrationAllowed: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedHeadlessTerminal: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const mounted = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd,
command: 'codex resume proven-absent-session',
worktreeId,
tabId,
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
})
expect(probePtyLiveness).toHaveBeenCalledWith('pty-proven-absent-owner')
// Proven absence is the one answer that authorizes retirement, so recovery must proceed.
expect(mounted).toMatchObject({ id: 'pty-fresh-proven' })
expect(providerSpawn).toHaveBeenCalledTimes(2)
expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({
command: 'codex resume proven-absent-session'
})
expect(runtime.onPtyExit).toHaveBeenCalledWith(
'pty-proven-absent-owner',
0,
'inc-proven-absent-owner'
)
expect(store.setWorkspaceSession).toHaveBeenCalledOnce()
expect(store.flushOrThrow).toHaveBeenCalledOnce()
})
it('retires a dead owner from the exact SSH host session before fresh recovery', async () => {
const connectionId = 'ssh-dead-stable-pane'
const hostId = `ssh:${connectionId}`
+9
View File
@@ -734,6 +734,15 @@ async function attachStablePaneOwner(
if (!isPtyAlreadyGoneError(error)) {
throw error
}
// Why: "Session not found" only proves the provider we asked has no such PTY — and a
// degraded router answers unmapped ids from the local fallback, which never owned a
// daemon session. Retiring on that would signal exit and delete a live agent's pane
// binding. Absence must be proven across every possible owner first; `null` (nobody
// could answer) is not absence. Providers without a probe are their own sole owner,
// so their refusal stays authoritative.
if (provider.probePtyLiveness && (await provider.probePtyLiveness(owner.ptyId)) !== false) {
throw new Error('terminal_pane_owner_unverified')
}
const ownerBeforeRetire = args.resolveOwner?.()
if (
ownerBeforeRetire &&