fix(terminal): recover live panes after renderer restart (STA-3536) (#12776)

* fix(terminal): stop transient probe blips from erroring restored panes (STA-3536)

terminal_pane_owner_unverified fired for every restored pane whenever one
liveness probe answer went missing: a cold-start daemon draining an attach
stampede misses the 2s getSize deadline, and a wedged superseded daemon
(protocol upgrades leave them running) turns every unmapped fan-out probe
null forever.

- probePtyOwners now skips legacy daemons whose startup inventory listing
  succeeded: fresh sessions never route to them, so they provably don't own
  an unmapped id and one wedged zombie can't poison every pane's verdict.
- attachStablePaneOwner retries the probe over a short backoff ladder before
  surfacing unverified, so a single missed deadline resolves to a verdict.
- The renderer replaces the raw error code with actionable copy.

* fix(terminal): stop retrying definitive owner probes

* fix(terminal): recover live panes after renderer restart

* refactor(terminal): share owner resolution abort guard
This commit is contained in:
Brennan Benson 2026-08-05 20:52:22 -07:00 committed by GitHub
parent ff01fad4ad
commit 81e7a2ddf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1651 additions and 159 deletions

View File

@ -18,6 +18,7 @@ import type {
DaemonEvent
} from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
import { decodeDaemonResponseError } from './daemon-errors'
const CONNECT_TIMEOUT_MS = 5000
const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
@ -429,7 +430,12 @@ export class DaemonClient {
if (response.ok) {
pending.resolve(response.payload)
} else {
pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error)))
const decoded = decodeDaemonResponseError(response.error)
pending.reject(
decoded instanceof DaemonProtocolError
? new DaemonProtocolError(addNodePtyRecoveryHint(response.error))
: decoded
)
}
}
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
DaemonProtocolError,
decodeDaemonResponseError,
SessionNotFoundError
} from './daemon-errors'
describe('decodeDaemonResponseError', () => {
it('types the exact legacy session-absence response', () => {
expect(decodeDaemonResponseError('Session not found: pty-1')).toBeInstanceOf(
SessionNotFoundError
)
})
it('keeps unrelated daemon failures non-authoritative', () => {
expect(decodeDaemonResponseError('proxy failed: Session not found: pty-1')).toBeInstanceOf(
DaemonProtocolError
)
})
})

View File

@ -20,3 +20,17 @@ export class SessionNotFoundError extends Error {
this.name = 'SessionNotFoundError'
}
}
export class TerminalSessionOwnerUnverifiedError extends Error {
constructor(sessionId: string) {
super(`Terminal session owner could not be verified: ${sessionId}`)
this.name = 'TerminalSessionOwnerUnverifiedError'
}
}
export function decodeDaemonResponseError(message: string): Error {
const prefix = 'Session not found: '
return message.startsWith(prefix)
? new SessionNotFoundError(message.slice(prefix.length))
: new DaemonProtocolError(message)
}

View File

@ -10,7 +10,8 @@ export class DaemonPtyAdapterSubscriptionFanout {
constructor(
private readonly adapters: readonly DaemonPtyAdapter[],
onAdapterExit: (id: string) => void
onAdapterExit: (id: string) => void,
onAdapterIdentityChanged?: (adapter: DaemonPtyAdapter) => void
) {
for (const adapter of adapters) {
this.unsubscribers.push(
@ -24,7 +25,10 @@ export class DaemonPtyAdapterSubscriptionFanout {
for (const listener of this.exitListeners) {
listener(payload)
}
})
}),
...(onAdapterIdentityChanged && typeof adapter.onDaemonIdentityChanged === 'function'
? [adapter.onDaemonIdentityChanged(() => onAdapterIdentityChanged(adapter))]
: [])
)
}
}

View File

@ -1,20 +0,0 @@
import type { IPtyProvider } from '../providers/types'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
export async function probePtyOwners(
id: string,
routed: IPtyProvider | undefined,
possibleOwners: readonly DaemonPtyAdapter[]
): Promise<boolean | null> {
if (routed) {
return routed.probePtyLiveness
? await routed.probePtyLiveness(id)
: (routed.hasPty?.(id) ?? null)
}
const results = await Promise.all(possibleOwners.map((provider) => provider.probePtyLiveness(id)))
return results.some((result) => result === true)
? true
: results.every((result) => result === false)
? false
: null
}

View File

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { DaemonPtyRouter } from './daemon-pty-router'
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import {
@ -18,6 +19,7 @@ type AdapterMock = DaemonPtyAdapter & {
emitData: (id: string, data: string, sequenceChars?: number) => void
emitBackground: (event: PtyBackgroundStreamEvent) => void
emitExit: (id: string, code: number, incarnationId?: string) => void
emitIdentityChange: () => void
triggerWriteUnavailable: (id: string) => void
}
@ -44,6 +46,7 @@ function createAdapter(
const writeUnavailableListeners: ((payload: { id: string }) => void)[] = []
const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] =
[]
const identityChangeListeners: (() => void)[] = []
return {
protocolVersion,
supportsGitCredentialGuardHost: () =>
@ -136,6 +139,15 @@ function createAdapter(
}
}
),
onDaemonIdentityChanged: vi.fn((callback: () => void) => {
identityChangeListeners.push(callback)
return () => {
const idx = identityChangeListeners.indexOf(callback)
if (idx !== -1) {
identityChangeListeners.splice(idx, 1)
}
}
}),
ackColdRestore: vi.fn(),
clearTombstone: vi.fn(),
reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }),
@ -156,6 +168,7 @@ function createAdapter(
listener({ id, code, ...(incarnationId ? { incarnationId } : {}) })
}
},
emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()),
triggerWriteUnavailable: (id: string) => {
for (const listener of writeUnavailableListeners) {
listener({ id })
@ -498,14 +511,16 @@ describe('DaemonPtyRouter', () => {
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})
it('probes every possible daemon owner for an unmapped session', async () => {
it('discovers an unmapped live session from one coalesced inventory', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy', ['surviving-session'])
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.probePtyLiveness('surviving-session')).resolves.toBe(true)
expect(current.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session')
expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session')
expect(current.listProcesses).toHaveBeenCalledOnce()
expect(legacy.listProcesses).toHaveBeenCalledOnce()
expect(current.probePtyLiveness).not.toHaveBeenCalled()
expect(legacy.probePtyLiveness).not.toHaveBeenCalled()
})
it('does not report absence while any possible daemon owner is unavailable', async () => {
@ -517,6 +532,102 @@ describe('DaemonPtyRouter', () => {
await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull()
})
it('routes an attach to a legacy session created after startup inventory', async () => {
const current = createAdapter('current')
const legacySessions = ['legacy-at-startup']
const legacy = createAdapter('legacy', legacySessions)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
legacySessions.push('legacy-created-later')
await router.spawn({
sessionId: 'legacy-created-later',
attachOnly: true,
cols: 80,
rows: 24
})
expect(legacy.spawn).toHaveBeenCalledExactlyOnceWith({
sessionId: 'legacy-created-later',
attachOnly: true,
cols: 80,
rows: 24
})
expect(current.spawn).not.toHaveBeenCalled()
})
it('still routes probes for a discovered session to its owning legacy daemon', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy', ['legacy-session'])
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
await expect(router.probePtyLiveness('legacy-session')).resolves.toBe(true)
expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('legacy-session')
})
it('keeps consulting a legacy daemon whose inventory listing failed', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy', ['legacy-session'])
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged'))
vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
// Without an inventory nothing proves the legacy daemon doesn't own this id.
await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull()
expect(legacy.listProcesses).toHaveBeenCalledTimes(2)
expect(legacy.probePtyLiveness).not.toHaveBeenCalled()
})
it('keeps an attach unresolved when any possible owner inventory fails', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy')
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged'))
await expect(
router.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 })
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
expect(current.spawn).not.toHaveBeenCalled()
expect(legacy.spawn).not.toHaveBeenCalled()
})
it('re-resolves a stale positive route before declaring an owner absent', async () => {
const current = createAdapter('current')
const firstSessions = ['moved-session']
const secondSessions: string[] = []
const first = createAdapter('first', firstSessions)
const second = createAdapter('second', secondSessions)
const router = new DaemonPtyRouter({ current, legacy: [first, second] })
await router.discoverLegacySessions()
firstSessions.length = 0
secondSessions.push('moved-session')
vi.mocked(first.spawn).mockRejectedValueOnce(new SessionNotFoundError('moved-session'))
await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 })
expect(first.spawn).toHaveBeenCalledOnce()
expect(second.spawn).toHaveBeenCalledOnce()
})
it('invalidates positive routes when a daemon endpoint identity changes', async () => {
const currentSessions: string[] = []
const legacySessions = ['moved-session']
const current = createAdapter('current', currentSessions)
const legacy = createAdapter('legacy', legacySessions)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
legacySessions.length = 0
currentSessions.push('moved-session')
legacy.emitIdentityChange()
await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 })
expect(current.spawn).toHaveBeenCalledOnce()
expect(legacy.spawn).not.toHaveBeenCalled()
})
it('hands a checkpointed pre-v30 session to the current daemon on wake', async () => {
const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION)
const legacy = createAdapter(
@ -582,7 +693,7 @@ describe('DaemonPtyRouter', () => {
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
expect(router.getLegacyAdapters()).toEqual([legacy])
expect(current.listProcesses).toHaveBeenCalledTimes(2)
expect(current.listProcesses).toHaveBeenCalledTimes(3)
})
it('pins colliding unmapped legacy ids falling through to the current daemon', async () => {

View File

@ -9,44 +9,44 @@ import type {
PtySpawnResult
} from '../providers/types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { probePtyOwners } from './daemon-pty-liveness-probe'
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
private legacy: DaemonPtyAdapter[]
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
private readonly ownerResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
this.current = opts.current
this.legacy = opts.legacy
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => {
this.sessionAdapters.delete(id)
})
this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters)
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(
this.allAdapters(),
(id) => {
this.ownerResolver.forgetRoute(id)
},
(adapter) => this.ownerResolver.invalidateProvider(adapter)
)
}
async discoverLegacySessions(): Promise<void> {
for (const adapter of this.legacy) {
try {
const sessions = await adapter.listProcesses()
for (const session of sessions) {
this.sessionAdapters.set(session.id, adapter)
}
} catch (error) {
console.warn('[daemon] Failed to discover legacy daemon sessions', error)
}
}
await this.ownerResolver.discoverRoutes()
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
if (opts.attachOnly && opts.sessionId) {
return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
}
const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined
const target = adapter ?? this.current
const result = await target.spawn(opts)
// Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof.
if (!result.exitedBeforeSpawnReply) {
this.sessionAdapters.set(result.id, target)
this.ownerResolver.recordRoute(result.id, target, result.incarnationId)
}
return result
}
@ -86,7 +86,7 @@ export class DaemonPtyRouter implements IPtyProvider {
}
async probePtyLiveness(id: string): Promise<boolean | null> {
return await probePtyOwners(id, this.sessionAdapters.get(id), this.allAdapters())
return await this.ownerResolver.probe(id)
}
write(id: string, data: string): void {
@ -121,7 +121,7 @@ export class DaemonPtyRouter implements IPtyProvider {
adapter.ackColdRestore(id)
}
if (this.sessionAdapters.get(id) === adapter) {
this.sessionAdapters.delete(id)
this.ownerResolver.forgetRoute(id, adapter)
}
}
}
@ -240,6 +240,7 @@ export class DaemonPtyRouter implements IPtyProvider {
}> {
const alive: string[] = []
const killed: string[] = []
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
for (const adapter of this.allAdapters()) {
const result = await adapter.reconcileOnStartup(validWorktreeIds)
// Why: daemon startup can reconcile many restored sessions; spreading
@ -251,10 +252,17 @@ export class DaemonPtyRouter implements IPtyProvider {
killed.push(id)
}
for (const id of result.alive) {
this.sessionAdapters.set(id, adapter)
const providers = aliveProviders.get(id) ?? new Set<DaemonPtyAdapter>()
providers.add(adapter)
aliveProviders.set(id, providers)
}
for (const id of result.killed) {
this.sessionAdapters.delete(id)
}
for (const id of new Set([...alive, ...killed])) {
const providers = aliveProviders.get(id)
if (providers?.size === 1) {
this.ownerResolver.recordRoute(id, providers.values().next().value!)
} else {
this.ownerResolver.forgetRoute(id)
}
}
return { alive, killed }

View File

@ -0,0 +1,450 @@
import { describe, expect, it, vi } from 'vitest'
import type {
IPtyProvider,
PtyProcessInfo,
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
function provider(
processes: () => Promise<PtyProcessInfo[]>,
spawn: (opts: PtySpawnOptions) => Promise<PtySpawnResult> = async (opts) => ({
id: opts.sessionId ?? 'new'
})
): IPtyProvider {
return {
listProcesses: vi.fn(processes),
spawn: vi.fn(spawn)
} as unknown as IPtyProvider
}
describe('DaemonSessionOwnerResolver', () => {
it('coalesces concurrent all-provider inventories', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const firstInventory = vi.fn(async () => {
await gate
return []
})
const secondInventory = vi.fn(async () => {
await gate
return []
})
const first = provider(firstInventory)
const second = provider(secondInventory)
const resolver = new DaemonSessionOwnerResolver([first, second], new Map())
const resolutions = Promise.all([resolver.resolve('one'), resolver.resolve('two')])
await vi.waitFor(() => expect(firstInventory).toHaveBeenCalledOnce())
expect(secondInventory).toHaveBeenCalledOnce()
release()
await expect(resolutions).resolves.toEqual([{ kind: 'unknown' }, { kind: 'unknown' }])
expect(firstInventory).toHaveBeenCalledOnce()
expect(secondInventory).toHaveBeenCalledOnce()
})
it('bounds restore inventory requests across many panes and daemon generations', async () => {
const inventories = Array.from({ length: 51 }, (_, index) =>
vi.fn(async () => {
if (index === 50) {
throw new Error('offline')
}
return []
})
)
const resolver = new DaemonSessionOwnerResolver(
inventories.map((inventory) => provider(inventory)),
new Map()
)
for (let index = 0; index < 40; index += 1) {
await resolver.resolve(`pane-${index}`)
}
for (const inventory of inventories) {
expect(inventory).toHaveBeenCalledOnce()
}
})
it('reuses incomplete inventory across serialized restores', async () => {
const firstInventory = vi.fn(async () => [])
const secondInventory = vi.fn(async () => {
throw new Error('offline')
})
const resolver = new DaemonSessionOwnerResolver(
[provider(firstInventory), provider(secondInventory)],
new Map()
)
await resolver.resolve('first')
await resolver.resolve('second')
expect(firstInventory).toHaveBeenCalledOnce()
expect(secondInventory).toHaveBeenCalledOnce()
})
it('does not extend incomplete inventory lifetime on cache hits', async () => {
let now = 1_000
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
const inventory = vi.fn(async () => {
throw new Error('offline')
})
const resolver = new DaemonSessionOwnerResolver(
[provider(async () => []), provider(inventory)],
new Map()
)
try {
await resolver.resolve('first')
now += 900
await resolver.resolve('second')
now += 101
await resolver.resolve('third')
expect(inventory).toHaveBeenCalledTimes(2)
} finally {
clock.mockRestore()
}
})
it('does not let startup inventory hide a session created before restore', async () => {
const sessions: PtyProcessInfo[] = []
const inventory = vi.fn(async () => sessions)
const owner = provider(inventory, async (opts) => ({
id: opts.sessionId!,
incarnationId: 'live',
isReattach: true
}))
const resolver = new DaemonSessionOwnerResolver([owner, provider(async () => [])], new Map())
await resolver.discoverRoutes()
sessions.push({ id: 'session', incarnationId: 'live', cwd: '', title: 'live' })
await expect(
resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true })
expect(inventory).toHaveBeenCalledTimes(2)
})
it('uses the expected incarnation to disambiguate duplicate session ids', async () => {
const oldOwner = provider(async () => [
{ id: 'session', incarnationId: 'old', cwd: '', title: 'old' }
])
const exactOwner = provider(async () => [
{ id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' }
])
const resolver = new DaemonSessionOwnerResolver([oldOwner, exactOwner], new Map())
await expect(resolver.resolve('session', 'expected')).resolves.toMatchObject({
kind: 'owner',
provider: exactOwner
})
})
it('accepts one exact-id owner when persisted incarnation evidence is stale', async () => {
const liveOwner = provider(async () => [
{ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }
])
const resolver = new DaemonSessionOwnerResolver([liveOwner], new Map())
await expect(resolver.resolve('session', 'stale')).resolves.toMatchObject({
kind: 'owner',
provider: liveOwner
})
})
it('does not inventory unrelated providers for a routed stale persisted incarnation', async () => {
const routedOwner = provider(
async () => [{ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }],
async () => ({ id: 'session', incarnationId: 'live', isReattach: true })
)
const unrelatedInventory = vi.fn(async () => {
throw new Error('offline')
})
const resolver = new DaemonSessionOwnerResolver(
[routedOwner, provider(unrelatedInventory)],
new Map([['session', routedOwner]])
)
await expect(
resolver.spawnAttachOnly({
sessionId: 'session',
expectedIncarnationId: 'stale',
attachOnly: true,
cols: 80,
rows: 24
})
).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true })
expect(unrelatedInventory).not.toHaveBeenCalled()
})
it('re-resolves a routed incarnation mismatch against every possible owner', async () => {
const staleRoute = provider(
async () => [{ id: 'session', incarnationId: 'other', cwd: '', title: 'stale' }],
async () => ({ id: 'session', incarnationId: 'other', isReattach: true })
)
const exactOwner = provider(
async () => [{ id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' }],
async () => ({ id: 'session', incarnationId: 'expected', isReattach: true })
)
const resolver = new DaemonSessionOwnerResolver(
[staleRoute, exactOwner],
new Map([['session', staleRoute]])
)
await expect(
resolver.spawnAttachOnly({
sessionId: 'session',
expectedIncarnationId: 'expected',
expectedIncarnationIsAuthoritative: true,
attachOnly: true,
cols: 80,
rows: 24
})
).resolves.toMatchObject({ incarnationId: 'expected' })
expect(staleRoute.spawn).not.toHaveBeenCalled()
})
it('refreshes incomplete inventory when a routed owner refuses a moved session', async () => {
let firstOwnsSession = true
let secondOwnsSession = false
const first = provider(
async () =>
firstOwnsSession
? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'first' }]
: [],
async () => {
throw new SessionNotFoundError('session')
}
)
const second = provider(
async () =>
secondOwnsSession
? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'second' }]
: [],
async () => ({ id: 'session', incarnationId: 'runtime', isReattach: true })
)
const unavailable = provider(async () => {
throw new Error('offline')
})
const resolver = new DaemonSessionOwnerResolver([first, second, unavailable], new Map())
await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({
kind: 'owner',
provider: first
})
firstOwnsSession = false
secondOwnsSession = true
await expect(
resolver.spawnAttachOnly({
sessionId: 'session',
expectedIncarnationId: 'runtime',
expectedIncarnationIsAuthoritative: true,
attachOnly: true,
cols: 80,
rows: 24
})
).resolves.toMatchObject({ id: 'session', incarnationId: 'runtime', isReattach: true })
expect(first.spawn).toHaveBeenCalledOnce()
expect(second.spawn).toHaveBeenCalledOnce()
expect(second.listProcesses).toHaveBeenCalledTimes(2)
})
it('accepts exact runtime proof from an incomplete inventory', async () => {
const exactOwner = provider(async () => [
{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'exact' }
])
const resolver = new DaemonSessionOwnerResolver(
[
exactOwner,
provider(async () => {
throw new Error('offline')
})
],
new Map()
)
await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({
kind: 'owner',
provider: exactOwner
})
})
it('does not trust persisted incarnation proof from an incomplete inventory', async () => {
const resolver = new DaemonSessionOwnerResolver(
[
provider(async () => [
{ id: 'session', incarnationId: 'persisted', cwd: '', title: 'candidate' }
]),
provider(async () => {
throw new Error('offline')
})
],
new Map()
)
await expect(resolver.resolve('session', 'persisted')).resolves.toEqual({ kind: 'unknown' })
})
it('accepts positive liveness proof from an incomplete inventory', async () => {
const resolver = new DaemonSessionOwnerResolver(
[
provider(async () => [{ id: 'session', cwd: '', title: 'live' }]),
provider(async () => {
throw new Error('offline')
})
],
new Map()
)
await expect(resolver.probe('session')).resolves.toBe(true)
})
it('fails closed when duplicate owners cannot be disambiguated', async () => {
const first = provider(async () => [{ id: 'session', cwd: '', title: 'first' }])
const second = provider(async () => [{ id: 'session', cwd: '', title: 'second' }])
const resolver = new DaemonSessionOwnerResolver([first, second], new Map())
await expect(resolver.resolve('session')).resolves.toEqual({ kind: 'unknown' })
})
it('reports confirmed absence from a sole owner', async () => {
const owner = provider(
async () => [],
async () => {
throw new SessionNotFoundError('missing')
}
)
const resolver = new DaemonSessionOwnerResolver([owner], new Map())
await expect(
resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 })
).rejects.toBeInstanceOf(SessionNotFoundError)
expect(owner.spawn).toHaveBeenCalledOnce()
expect(owner.listProcesses).not.toHaveBeenCalled()
})
it('keeps absence unverified when there are no possible owners', async () => {
const resolver = new DaemonSessionOwnerResolver([], new Map())
await expect(resolver.resolve('missing')).resolves.toEqual({ kind: 'unknown' })
})
it('preserves an unresolved owner when any provider inventory fails', async () => {
const resolver = new DaemonSessionOwnerResolver(
[
provider(async () => []),
provider(async () => {
throw new Error('offline')
})
],
new Map()
)
await expect(
resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 })
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
})
it('does not turn a raced owner refusal into absence while another provider is unresolved', async () => {
const candidate = provider(
async () => [{ id: 'session', cwd: '', title: 'candidate' }],
async () => {
throw new SessionNotFoundError('session')
}
)
const resolver = new DaemonSessionOwnerResolver(
[
candidate,
provider(async () => {
throw new Error('offline')
})
],
new Map()
)
await expect(
resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
})
it('does not turn a post-inventory owner refusal into aggregate absence', async () => {
const candidate = provider(
async () => [{ id: 'session', cwd: '', title: 'candidate' }],
async () => {
throw new SessionNotFoundError('session')
}
)
const resolver = new DaemonSessionOwnerResolver(
[candidate, provider(async () => [])],
new Map()
)
await expect(
resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
})
it('pre-routes every uniquely inventoried session for serialized restores', async () => {
const inventory = vi.fn(async () => [
{ id: 'first', cwd: '', title: 'first' },
{ id: 'second', cwd: '', title: 'second' }
])
const owner = provider(inventory)
const emptyInventory = vi.fn(async () => [])
const resolver = new DaemonSessionOwnerResolver([owner, provider(emptyInventory)], new Map())
await resolver.spawnAttachOnly({ sessionId: 'first', attachOnly: true, cols: 80, rows: 24 })
await resolver.spawnAttachOnly({ sessionId: 'second', attachOnly: true, cols: 80, rows: 24 })
expect(inventory).toHaveBeenCalledOnce()
expect(emptyInventory).toHaveBeenCalledOnce()
})
it('discards an inventory completed after daemon identity replacement', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const inventory = vi.fn(async () => {
await gate
return [{ id: 'session', cwd: '', title: 'stale' }]
})
const owner = provider(inventory)
const resolver = new DaemonSessionOwnerResolver([owner], new Map())
const resolution = resolver.resolve('session')
await vi.waitFor(() => expect(inventory).toHaveBeenCalledOnce())
resolver.invalidateProvider(owner)
release()
await expect(resolution).resolves.toEqual({ kind: 'unknown' })
await resolver.resolve('session')
expect(inventory).toHaveBeenCalledTimes(2)
})
it('restores a proven route when identity changes during exact reattach', async () => {
const routes = new Map<string, IPtyProvider>()
let resolver!: DaemonSessionOwnerResolver<IPtyProvider>
let owner!: IPtyProvider
owner = provider(
async () => [],
async () => {
resolver.invalidateProvider(owner)
return { id: 'session', incarnationId: 'inc', isReattach: true }
}
)
routes.set('session', owner)
resolver = new DaemonSessionOwnerResolver([owner], routes)
await resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
expect(routes.get('session')).toBe(owner)
})
})

View File

@ -0,0 +1,300 @@
import type {
IPtyProvider,
PtyProcessInfo,
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
export type DaemonSessionOwnerResolution<T extends IPtyProvider> =
| { kind: 'owner'; provider: T }
| { kind: 'absent' }
| { kind: 'unknown' }
type ProviderInventory<T> = { provider: T; processes: PtyProcessInfo[] | null }
type OwnerInventory<T extends IPtyProvider> = {
candidatesBySessionId: Map<string, { provider: T; process: PtyProcessInfo }[]>
complete: boolean
epoch: number
}
const OWNER_RESOLUTION_TIMEOUT_MS = 2_000
const OWNER_INVENTORY_CACHE_MS = 1_000
const FAILED_PROVIDER_COOLDOWN_MS = 1_000
function assertClientConnected(signal: PtySpawnOptions['signal']): void {
if (signal?.aborted) {
throw new Error('client_disconnected')
}
}
export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
private inventoryInFlight: Promise<OwnerInventory<T>> | null = null
private cachedInventory: { value: OwnerInventory<T>; expiresAt: number } | null = null
private readonly failedProviderCooldowns = new Map<T, number>()
private readonly routeIncarnations = new Map<string, string | undefined>()
private epoch = 0
constructor(
private readonly providers: readonly T[],
private readonly routes: Map<string, IPtyProvider>
) {}
invalidateProvider(provider: T): void {
this.epoch += 1
this.inventoryInFlight = null
this.cachedInventory = null
this.failedProviderCooldowns.clear()
for (const [sessionId, routed] of this.routes) {
if (routed === provider) {
this.routes.delete(sessionId)
this.routeIncarnations.delete(sessionId)
}
}
}
async spawnAttachOnly(opts: PtySpawnOptions & { sessionId: string }): Promise<PtySpawnResult> {
assertClientConnected(opts.signal)
const routed = this.providers.find((provider) => provider === this.routes.get(opts.sessionId))
const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined)
const routedIncarnation = this.routeIncarnations.get(opts.sessionId)
const routeNeedsAuthoritativeResolution =
direct &&
routed &&
opts.expectedIncarnationIsAuthoritative === true &&
routedIncarnation !== opts.expectedIncarnationId
if (direct && !routeNeedsAuthoritativeResolution) {
try {
const result = await direct.spawn(opts)
if (
!result.exitedBeforeSpawnReply &&
result.id === opts.sessionId &&
result.isReattach === true
) {
this.recordRoute(result.id, direct, result.incarnationId)
}
return result
} catch (error) {
if (!(error instanceof SessionNotFoundError)) {
throw error
}
if (this.providers.length === 1) {
throw error
}
if (routed && this.routes.get(opts.sessionId) === routed) {
this.forgetRoute(opts.sessionId, routed)
}
}
}
assertClientConnected(opts.signal)
const resolution = await this.resolve(
opts.sessionId,
opts.expectedIncarnationId,
opts.expectedIncarnationIsAuthoritative
)
assertClientConnected(opts.signal)
if (resolution.kind === 'unknown') {
throw new TerminalSessionOwnerUnverifiedError(opts.sessionId)
}
if (resolution.kind === 'absent') {
throw new SessionNotFoundError(opts.sessionId)
}
try {
const result = await resolution.provider.spawn(opts)
if (
!result.exitedBeforeSpawnReply &&
result.id === opts.sessionId &&
result.isReattach === true
) {
this.recordRoute(result.id, resolution.provider, result.incarnationId)
}
return result
} catch (error) {
if (error instanceof SessionNotFoundError && this.providers.length > 1) {
throw new TerminalSessionOwnerUnverifiedError(opts.sessionId)
}
throw error
}
}
async probe(sessionId: string): Promise<boolean | null> {
const routed = this.providers.find((provider) => provider === this.routes.get(sessionId))
const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined)
if (direct) {
const verdict = direct.probePtyLiveness
? await direct.probePtyLiveness(sessionId)
: (direct.hasPty?.(sessionId) ?? null)
if (verdict !== false || this.providers.length === 1) {
return verdict
}
if (this.routes.get(sessionId) === routed) {
this.routes.delete(sessionId)
this.routeIncarnations.delete(sessionId)
}
}
const inventory = await this.inventory(false)
if (inventory.epoch !== this.epoch) {
return null
}
if ((inventory.candidatesBySessionId.get(sessionId)?.length ?? 0) > 0) {
return true
}
const resolution = this.resolveInventory(inventory, sessionId)
if (resolution.kind === 'owner') {
return resolution.provider === routed ? null : true
}
return resolution.kind === 'absent' ? false : null
}
async resolve(
sessionId: string,
expectedIncarnationId?: string,
expectedIncarnationIsAuthoritative = false
): Promise<DaemonSessionOwnerResolution<T>> {
const inventory = await this.inventory()
if (inventory.epoch !== this.epoch) {
return { kind: 'unknown' }
}
const resolution = this.resolveInventory(
inventory,
sessionId,
expectedIncarnationId,
expectedIncarnationIsAuthoritative
)
if (
(!inventory.complete || resolution.kind === 'unknown') &&
this.cachedInventory?.value !== inventory
) {
this.cachedInventory = {
value: inventory,
expiresAt: Date.now() + OWNER_INVENTORY_CACHE_MS
}
}
return resolution
}
async discoverRoutes(): Promise<void> {
await this.inventory()
this.failedProviderCooldowns.clear()
this.cachedInventory = null
}
recordRoute(sessionId: string, provider: T, incarnationId?: string): void {
this.routes.set(sessionId, provider)
this.routeIncarnations.set(sessionId, incarnationId)
}
forgetRoute(sessionId: string, provider?: T): void {
if (provider && this.routes.get(sessionId) !== provider) {
return
}
this.routes.delete(sessionId)
this.routeIncarnations.delete(sessionId)
this.cachedInventory = null
}
private resolveInventory(
inventory: OwnerInventory<T>,
sessionId: string,
expectedIncarnationId?: string,
expectedIncarnationIsAuthoritative = false
): DaemonSessionOwnerResolution<T> {
const candidates = inventory.candidatesBySessionId.get(sessionId) ?? []
const providers = new Set(candidates.map(({ provider }) => provider))
const exactProviders = new Set(
candidates
.filter(({ process }) => process.incarnationId === expectedIncarnationId)
.map(({ provider }) => provider)
)
const exactProvider =
expectedIncarnationId && exactProviders.size === 1
? exactProviders.values().next().value
: undefined
const soleProvider = providers.size === 1 ? providers.values().next().value : undefined
const provider =
(exactProvider && (inventory.complete || expectedIncarnationIsAuthoritative)
? exactProvider
: undefined) ??
(!expectedIncarnationIsAuthoritative && inventory.complete ? soleProvider : undefined)
if (provider) {
const process = candidates.find((candidate) => candidate.provider === provider)?.process
this.recordRoute(sessionId, provider, process?.incarnationId)
return { kind: 'owner', provider }
}
if (!inventory.complete || providers.size > 1 || this.providers.length !== 1) {
return { kind: 'unknown' }
}
return { kind: 'absent' }
}
private inventory(allowCached = true): Promise<OwnerInventory<T>> {
if (this.inventoryInFlight) {
return this.inventoryInFlight
}
if (
allowCached &&
this.cachedInventory?.value.epoch === this.epoch &&
this.cachedInventory.expiresAt > Date.now()
) {
return Promise.resolve(this.cachedInventory.value)
}
this.cachedInventory = null
const deadlineMs = Date.now() + OWNER_RESOLUTION_TIMEOUT_MS
const epoch = this.epoch
const inventory = Promise.all(
this.providers.map(async (provider): Promise<ProviderInventory<T>> => {
if ((this.failedProviderCooldowns.get(provider) ?? 0) > Date.now()) {
return { provider, processes: null }
}
this.failedProviderCooldowns.delete(provider)
try {
return { provider, processes: await provider.listProcesses({ deadlineMs }) }
} catch {
if (epoch === this.epoch) {
this.failedProviderCooldowns.set(provider, Date.now() + FAILED_PROVIDER_COOLDOWN_MS)
}
return { provider, processes: null }
}
})
)
.then((entries) => this.indexInventory(entries, epoch))
.finally(() => {
if (this.inventoryInFlight === inventory) {
this.inventoryInFlight = null
}
})
this.inventoryInFlight = inventory
return inventory
}
private indexInventory(entries: ProviderInventory<T>[], epoch: number): OwnerInventory<T> {
const candidatesBySessionId = new Map<string, { provider: T; process: PtyProcessInfo }[]>()
let complete = true
for (const entry of entries) {
if (!entry.processes) {
complete = false
continue
}
for (const process of entry.processes) {
const candidates = candidatesBySessionId.get(process.id) ?? []
candidates.push({ provider: entry.provider, process })
candidatesBySessionId.set(process.id, candidates)
}
}
if (complete && epoch === this.epoch) {
for (const [sessionId, candidates] of candidatesBySessionId) {
const providers = new Set(candidates.map(({ provider }) => provider))
if (providers.size === 1) {
const provider = providers.values().next().value!
const process = candidates.find((candidate) => candidate.provider === provider)?.process
this.recordRoute(sessionId, provider, process?.incarnationId)
} else {
this.forgetRoute(sessionId)
}
}
}
return { candidatesBySessionId, complete, epoch }
}
}

View File

@ -0,0 +1,82 @@
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
export class DegradedDaemonOwnerRecovery {
private readonly attachResolver: DaemonSessionOwnerResolver<IPtyProvider>
private readonly livenessResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
constructor(
providers: readonly IPtyProvider[],
private readonly daemonAdapters: readonly DaemonPtyAdapter[],
routes: Map<string, IPtyProvider>,
private readonly spawnFresh: (opts: PtySpawnOptions) => Promise<PtySpawnResult>
) {
this.attachResolver = new DaemonSessionOwnerResolver(providers, routes)
this.livenessResolver = new DaemonSessionOwnerResolver(daemonAdapters, routes)
}
spawn = async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
return opts.attachOnly && opts.sessionId
? await this.attachResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
: await this.spawnFresh(opts)
}
probe = async (sessionId: string): Promise<boolean | null> =>
await this.livenessResolver.probe(sessionId)
discoverRoutes = async (): Promise<void> => await this.attachResolver.discoverRoutes()
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
}> {
const alive: string[] = []
const killed: string[] = []
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
for (const adapter of this.daemonAdapters) {
const result = await adapter.reconcileOnStartup(validWorktreeIds)
for (const id of result.alive) {
alive.push(id)
const providers = aliveProviders.get(id) ?? new Set<DaemonPtyAdapter>()
providers.add(adapter)
aliveProviders.set(id, providers)
}
killed.push(...result.killed)
}
for (const id of new Set([...alive, ...killed])) {
const providers = aliveProviders.get(id)
if (providers?.size === 1) {
this.recordRoute(id, providers.values().next().value!)
} else {
this.forgetRoute(id)
}
}
return { alive, killed }
}
recordRoute(sessionId: string, provider: IPtyProvider): void {
this.attachResolver.recordRoute(sessionId, provider)
if (this.daemonAdapters.includes(provider as DaemonPtyAdapter)) {
this.livenessResolver.recordRoute(sessionId, provider as DaemonPtyAdapter)
}
}
forgetRoute(sessionId: string): void {
this.attachResolver.forgetRoute(sessionId)
this.livenessResolver.forgetRoute(sessionId)
}
subscribeIdentityChanges(): (() => void)[] {
return this.daemonAdapters.flatMap((adapter) =>
typeof adapter.onDaemonIdentityChanged === 'function'
? [
adapter.onDaemonIdentityChanged(() => {
this.attachResolver.invalidateProvider(adapter)
this.livenessResolver.invalidateProvider(adapter)
})
]
: []
)
}
}

View File

@ -4,6 +4,7 @@ import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { TerminalSessionOwnerUnverifiedError } from './daemon-errors'
type ProviderMock = IPtyProvider & {
probePtyLiveness: (id: string) => Promise<boolean | null>
@ -158,6 +159,47 @@ it('forwards dead-endpoint write-unavailable signals from the daemon adapters',
expect(recovered).toEqual(['daemon-pane', 'legacy-pane'])
})
it('routes attach-only to a legacy session created after startup inventory', async () => {
const current = createDaemonAdapter('daemon')
const legacySessions = ['legacy-at-startup']
const legacy = createDaemonAdapter('legacy', legacySessions)
const fallback = createProvider('fallback')
const provider = new DegradedDaemonPtyProvider({
current,
legacy: [legacy],
fallback
})
await provider.discoverDaemonSessions()
legacySessions.push('legacy-created-later')
await provider.spawn({
sessionId: 'legacy-created-later',
attachOnly: true,
cols: 80,
rows: 24
})
expect(legacy.spawn).toHaveBeenCalledOnce()
expect(current.spawn).not.toHaveBeenCalled()
expect(fallback.spawn).not.toHaveBeenCalled()
})
it('keeps an attach unresolved when a legacy inventory listing fails', async () => {
const current = createDaemonAdapter('daemon')
const legacy = createDaemonAdapter('legacy')
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged'))
const provider = new DegradedDaemonPtyProvider({
current,
legacy: [legacy],
fallback: createProvider('fallback')
})
await expect(
provider.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 })
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
expect(current.spawn).not.toHaveBeenCalled()
})
it('rejects completion inspection instead of borrowing the fallback provider', async () => {
const provider = new DegradedDaemonPtyProvider({
current: createDaemonAdapter('daemon'),
@ -368,7 +410,8 @@ describe('DegradedDaemonPtyProvider', () => {
})
it('probes daemon owners without borrowing fallback liveness', async () => {
const current = createDaemonAdapter('current')
const currentSessions: string[] = []
const current = createDaemonAdapter('current', currentSessions)
const legacy = createDaemonAdapter('legacy')
const fallback = createProvider('fallback', ['unknown-session'])
const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback })
@ -377,7 +420,7 @@ describe('DegradedDaemonPtyProvider', () => {
await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull()
expect(fallback.probePtyLiveness).not.toHaveBeenCalled()
vi.mocked(current.probePtyLiveness).mockResolvedValue(true)
currentSessions.push('unknown-session')
await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true)
})
@ -529,6 +572,6 @@ describe('DegradedDaemonPtyProvider', () => {
await expect(provider.listProcesses()).rejects.toThrow('legacy exited')
expect(provider.getLegacyAdapters()).toEqual([legacy])
expect(current.listProcesses).toHaveBeenCalledTimes(3)
expect(fallback.listProcesses).toHaveBeenCalledTimes(2)
expect(fallback.listProcesses).toHaveBeenCalledTimes(3)
})
})

View File

@ -14,12 +14,11 @@ import type {
import {
adoptOwningProvider,
attachDaemonOwnedSession,
discoverDegradedDaemonSessions,
findDaemonAdapter,
listProviderSessionIds
} from './degraded-daemon-session-routing'
import { probePtyOwners } from './daemon-pty-liveness-probe'
import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing'
import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery'
export class DegradedDaemonPtyProvider implements IPtyProvider {
readonly isDegraded = true
@ -29,6 +28,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
private fallback: IPtyProvider
private sessionProviders = new Map<string, IPtyProvider>()
private freshSpawns: DegradedDaemonFreshSpawnRouter
private ownerRecovery: DegradedDaemonOwnerRecovery
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: PtyDataEvent) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
@ -48,26 +48,27 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
this.sessionProviders,
opts.probeCurrentDaemonSpawn ?? null
)
this.ownerRecovery = new DegradedDaemonOwnerRecovery(
this.allProviders(),
this.allDaemonAdapters(),
this.sessionProviders,
(spawnOpts) => this.freshSpawns.spawn(spawnOpts)
)
for (const provider of this.allProviders()) {
this.unsubscribers.push(
provider.onData((payload) => {
for (const listener of this.dataListeners) {
listener(payload)
}
}),
provider.onData((payload) => this.dataListeners.forEach((listener) => listener(payload))),
provider.onExit((payload) => {
this.sessionProviders.delete(payload.id)
for (const listener of this.exitListeners) {
listener(payload)
}
this.ownerRecovery.forgetRoute(payload.id)
this.exitListeners.forEach((listener) => listener(payload))
})
)
}
this.unsubscribers.push(...this.ownerRecovery.subscribeIdentityChanges())
}
discoverDaemonSessions(): Promise<void> {
return discoverDegradedDaemonSessions(this.allDaemonAdapters(), this.sessionProviders)
async discoverDaemonSessions(): Promise<void> {
await this.ownerRecovery.discoverRoutes()
}
get routesFreshSpawnsToLocalProvider(): true | undefined {
@ -82,7 +83,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
canProvideAuthoritativeBufferSnapshot = (id: string): boolean =>
this.freshSpawns.canProvideSnapshot(id)
spawn = (opts: PtySpawnOptions): Promise<PtySpawnResult> => this.freshSpawns.spawn(opts)
spawn = (opts: PtySpawnOptions): Promise<PtySpawnResult> => this.ownerRecovery.spawn(opts)
// Why refuse the fallback route (unknown ids resolve to it): see attachDaemonOwnedSession.
attach = (id: string): Promise<void> =>
@ -94,7 +95,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
}
async probePtyLiveness(id: string): Promise<boolean | null> {
return await probePtyOwners(id, this.sessionProviders.get(id), this.allDaemonAdapters())
return await this.ownerRecovery.probe(id)
}
// Why: an unknown id cannot borrow listing authority from the fresh-spawn provider.
@ -275,20 +276,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
alive: string[]
killed: string[]
}> {
const alive: string[] = []
const killed: string[] = []
for (const adapter of this.allDaemonAdapters()) {
const result = await adapter.reconcileOnStartup(validWorktreeIds)
for (const id of result.alive) {
alive.push(id)
this.sessionProviders.set(id, adapter)
}
for (const id of result.killed) {
killed.push(id)
this.sessionProviders.delete(id)
}
}
return { alive, killed }
return await this.ownerRecovery.reconcileOnStartup(validWorktreeIds)
}
dispose(): void {

View File

@ -2,21 +2,6 @@ import type { IPtyProvider } from '../providers/types'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { SessionNotFoundError } from './daemon-errors'
export async function discoverDegradedDaemonSessions(
adapters: readonly DaemonPtyAdapter[],
sessionProviders: Map<string, IPtyProvider>
): Promise<void> {
for (const adapter of adapters) {
try {
for (const session of await adapter.listProcesses()) {
sessionProviders.set(session.id, adapter)
}
} catch (error) {
console.warn('[daemon] Failed to discover degraded daemon sessions', error)
}
}
}
export function listProviderSessionIds(
sessionProviders: ReadonlyMap<string, IPtyProvider>,
provider: IPtyProvider

View File

@ -14,6 +14,7 @@ import type { TuiAgent } from '../../shared/types'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
@ -9009,14 +9010,15 @@ describe('registerPtyHandlers', () => {
}
)
it('adopts an exact persisted owner when the runtime projection is missing', async () => {
it('repairs a stale persisted incarnation after exact same-id reattach', async () => {
const tabId = 'tab-persisted-owner'
const leafId = '88888888-8888-4888-8888-888888888888'
const paneKey = makePaneKey(tabId, leafId)
const worktreeId = 'repo-1::/tmp/persisted-owner'
let attachAttempt = 0
const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => ({
id: options.sessionId ?? 'unexpected-fresh-id',
incarnationId: 'inc-persisted-owner',
incarnationId: attachAttempt++ === 1 ? 'inc-wrong-owner' : 'inc-live-owner',
isReattach: options.attachOnly === true,
snapshot: 'persisted-owner-output'
}))
@ -9067,10 +9069,10 @@ describe('registerPtyHandlers', () => {
[tabId]: { ptyIdsByLeafId: { [leafId]: 'pty-persisted-owner' } }
},
terminalPtyIncarnationsByPaneKey: {
[paneKey]: 'inc-persisted-owner'
[paneKey]: 'inc-stale-owner'
}
})),
persistPtyBinding: vi.fn()
persistPtyBinding: vi.fn(() => true)
}
registerPtyHandlers(
@ -9081,7 +9083,7 @@ describe('registerPtyHandlers', () => {
undefined,
store as never
)
const mounted = await handlers.get('pty:spawn')!(null, {
const spawnArgs = {
cols: 80,
rows: 24,
cwd: '/tmp/persisted-owner',
@ -9094,18 +9096,22 @@ describe('registerPtyHandlers', () => {
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
})
}
const mounted = await handlers.get('pty:spawn')!(null, spawnArgs)
expect(mounted).toMatchObject({
id: 'pty-persisted-owner',
incarnationId: 'inc-persisted-owner',
incarnationId: 'inc-live-owner',
isReattach: true
})
expect(providerSpawn).toHaveBeenCalledOnce()
expect(providerSpawn).toHaveBeenCalledWith(
expect(providerSpawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
attachOnly: true,
sessionId: 'pty-persisted-owner',
expectedIncarnationId: 'inc-stale-owner',
expectedIncarnationIsAuthoritative: false,
command: undefined
})
)
@ -9115,10 +9121,50 @@ describe('registerPtyHandlers', () => {
)
expect(runtime.noteTerminalSpawnCommand).not.toHaveBeenCalled()
expect(store.persistPtyBinding).toHaveBeenCalledOnce()
expect(store.persistPtyBinding).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId,
tabId,
leafId,
ptyId: 'pty-persisted-owner',
incarnationId: 'inc-live-owner',
expectedBinding: {
ptyId: 'pty-persisted-owner',
incarnationId: 'inc-stale-owner'
}
}),
undefined
)
expect(
mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned')
).toHaveLength(1)
expect(runtime.onPtyExit).not.toHaveBeenCalled()
store.persistPtyBinding.mockClear()
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow(
'terminal_pane_owner_changed'
)
expect(store.persistPtyBinding).not.toHaveBeenCalled()
runtime.assertPtyRegistrationAllowed.mockImplementationOnce(() => {
throw new Error('agent_session_exited_during_start')
})
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow(
'agent_session_exited_during_start'
)
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(providerSpawn).toHaveBeenCalledTimes(3)
expect(providerSpawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
attachOnly: true,
sessionId: 'pty-persisted-owner',
expectedIncarnationId: 'inc-live-owner',
expectedIncarnationIsAuthoritative: true,
command: undefined
})
)
clearProviderPtyState('pty-persisted-owner')
})
it.each([
@ -9295,29 +9341,22 @@ 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 }) => {
it('keeps a persisted owner when daemon routing is unresolved', async () => {
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')
throw new TerminalSessionOwnerUnverifiedError('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(),
@ -9410,7 +9449,6 @@ describe('registerPtyHandlers', () => {
})
).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 })
@ -9420,11 +9458,7 @@ describe('registerPtyHandlers', () => {
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 () => {
it('still retires and respawns when the routed provider confirms absence', async () => {
const worktreeId = 'repo-1::/tmp/proven-absent-owner'
const cwd = '/tmp/proven-absent-owner'
const tabId = 'tab-proven-absent-owner'
@ -9438,10 +9472,8 @@ describe('registerPtyHandlers', () => {
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(),
@ -9532,8 +9564,6 @@ describe('registerPtyHandlers', () => {
}
})
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({
@ -9548,6 +9578,124 @@ describe('registerPtyHandlers', () => {
expect(store.flushOrThrow).toHaveBeenCalledOnce()
})
it('does not poll after the routed provider confirms absence', async () => {
const worktreeId = 'repo-1::/tmp/probe-blip-owner'
const cwd = '/tmp/probe-blip-owner'
const tabId = 'tab-probe-blip-owner'
const leafId = '67676767-6767-4767-8767-676767676767'
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-probe-blip-owner')
}
return { id: 'pty-fresh-probe-blip', incarnationId: 'inc-fresh-probe-blip' }
}
)
const probePtyLiveness = vi.fn(async () => null)
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-probe-blip-owner' }]
},
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-probe-blip-owner' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-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-probe-blip'),
preAllocateHandleForPty: vi.fn(() => 'term-probe-blip'),
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 probe-blip-session',
worktreeId,
tabId,
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
})
expect(probePtyLiveness).not.toHaveBeenCalled()
expect(mounted).toMatchObject({ id: 'pty-fresh-probe-blip' })
expect(providerSpawn).toHaveBeenCalledTimes(2)
expect(runtime.onPtyExit).toHaveBeenCalledWith(
'pty-probe-blip-owner',
0,
'inc-probe-blip-owner'
)
})
// Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while
// persistence has already dropped it. Reading "nothing left to retire" as a competing owner
// aborted materialization *after* signalling the exit, which destroyed the pane instead of
@ -9676,7 +9824,7 @@ describe('registerPtyHandlers', () => {
}
})
expect(probePtyLiveness).toHaveBeenCalledWith('pty-already-retired-owner')
expect(probePtyLiveness).not.toHaveBeenCalled()
expect(mounted).toMatchObject({ id: 'pty-fresh-already-retired' })
expect(providerSpawn).toHaveBeenCalledTimes(2)
expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({

View File

@ -93,6 +93,7 @@ import {
import { resolveWslSessionContext } from '../daemon/wsl-session-context'
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe'
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment'
@ -599,6 +600,9 @@ type StablePaneOwner = {
leafId: string
ptyId: string
incarnationId?: string
hasPersistedBinding?: true
persistedIncarnationId?: string
runtimeIncarnationId?: string
}
type StablePaneAdoption = {
result: PtySpawnResult
@ -677,13 +681,6 @@ function resolveStablePaneOwner(
throw new Error('terminal_pane_owner_host_mismatch')
}
const runtimeIncarnationId = ptyIncarnationById.get(ptyId)
if (
runtimeIncarnationId &&
persisted?.incarnationId &&
runtimeIncarnationId !== persisted.incarnationId
) {
throw new Error('terminal_pane_owner_conflict')
}
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return null
@ -695,7 +692,10 @@ function resolveStablePaneOwner(
ptyId,
...(runtimeIncarnationId || persisted?.incarnationId
? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId }
: {})
: {}),
...(persisted ? { hasPersistedBinding: true as const } : {}),
...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}),
...(runtimeIncarnationId ? { runtimeIncarnationId } : {})
}
}
@ -717,7 +717,7 @@ function retirePersistedStablePaneOwner(
// not a competing owner. Reporting failure here strands the pane after its PTY is proven dead.
return true
}
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) {
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) {
return false
}
const session = store.getWorkspaceSession(hostId)
@ -726,7 +726,7 @@ function retirePersistedStablePaneOwner(
parentTabId: owner.tabId,
leafId: owner.leafId,
ptyId: owner.ptyId,
...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {})
...(current.incarnationId ? { incarnationId: current.incarnationId } : {})
})
if (retired === session) {
return false
@ -748,6 +748,47 @@ type StablePaneSpawnContext = {
onFreshSpawn?: (result: PtySpawnResult) => void
}
function stablePanePersistenceFence(
owner: StablePaneOwner | null
): { ptyId: string; incarnationId?: string } | undefined {
return owner?.hasPersistedBinding
? {
ptyId: owner.ptyId,
...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {})
}
: undefined
}
function persistAdmittedStablePaneBinding(args: {
store: Store | undefined
owner: StablePaneOwner | null
result: PtySpawnResult
worktreeId: string | undefined
startupCwd: string | undefined
connectionId: string | null | undefined
}): boolean {
const expectedBinding = stablePanePersistenceFence(args.owner)
if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) {
return false
}
const persisted = args.store.persistPtyBinding(
{
worktreeId: args.worktreeId,
tabId: args.owner.tabId,
leafId: args.owner.leafId,
ptyId: args.result.id,
...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}),
...(args.startupCwd ? { startupCwd: args.startupCwd } : {}),
expectedBinding
},
args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
)
if (persisted === false) {
throw new Error('terminal_pane_owner_changed')
}
return true
}
async function attachStablePaneOwner(
args: StablePaneSpawnContext & { owner: StablePaneOwner }
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> {
@ -758,6 +799,8 @@ async function attachStablePaneOwner(
...spawnOptions,
sessionId: owner.ptyId,
attachOnly: true,
expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId,
expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined,
isNewSession: undefined,
command: undefined,
commandDelivery: undefined,
@ -769,25 +812,19 @@ async function attachStablePaneOwner(
onPtySpawnCommitted: undefined
})
} catch (error) {
if (error instanceof TerminalSessionOwnerUnverifiedError) {
throw new Error('terminal_pane_owner_unverified')
}
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 &&
(ownerBeforeRetire.ptyId !== owner.ptyId ||
(ownerBeforeRetire.incarnationId !== undefined &&
owner.incarnationId !== undefined &&
ownerBeforeRetire.incarnationId !== owner.incarnationId))
ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId ||
ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding ||
ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId)
) {
throw new Error('terminal_pane_owner_changed')
}
@ -808,7 +845,9 @@ async function attachStablePaneOwner(
if (
result.id !== owner.ptyId ||
result.isReattach !== true ||
(owner.incarnationId !== undefined && result.incarnationId !== owner.incarnationId)
(owner.runtimeIncarnationId !== undefined &&
result.incarnationId !== owner.runtimeIncarnationId) ||
(result.incarnationId === undefined && owner.incarnationId !== undefined)
) {
throw new Error('terminal_pane_owner_changed')
}
@ -4680,6 +4719,7 @@ export function registerPtyHandlers(
: null
let result: PtySpawnResult
let stablePaneOwner: StablePaneOwner | null = null
let stablePaneBindingPersisted = false
let rejectedRegistrationCandidate: PtySpawnResult | null = null
let pendingRegistrationPtyId: string | null = null
let preparedProvisionalExecutionContext = false
@ -4902,6 +4942,24 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
}
}
try {
stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
store: hostSessionBinding?.store,
owner: stablePaneOwner,
result,
worktreeId: hostSessionBinding?.worktreeId,
startupCwd: cwd,
connectionId: args.connectionId
})
} catch (error) {
if (error instanceof Error && error.message === 'terminal_pane_owner_changed') {
throw error
}
console.error('[pty] failed to persist runtime PTY binding after attach:', error)
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
if (result.agentSessionEnsure?.disposition === 'adopted') {
const owner = result.agentSessionEnsure.owner
ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null)
@ -4969,7 +5027,7 @@ export function registerPtyHandlers(
target: codexSelectionTarget,
settings: getSettings?.()
})
if (hostSessionBinding) {
if (hostSessionBinding && !stablePaneBindingPersisted) {
try {
const binding = {
worktreeId: hostSessionBinding.worktreeId,
@ -5625,6 +5683,7 @@ export function registerPtyHandlers(
let finishTerminalInstall = (): void => {}
let result: PtySpawnResult
let stablePaneOwner: StablePaneOwner | null = null
let stablePaneBindingPersisted = false
let rejectedRegistrationCandidate: PtySpawnResult | null = null
let pendingRegistrationPtyId: string | null = null
let preparedProvisionalExecutionContext = false
@ -6245,6 +6304,24 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv.delete(preAllocatedHandle)
}
}
try {
stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
store,
owner: stablePaneOwner,
result,
worktreeId: args.worktreeId,
startupCwd: cwd,
connectionId: args.connectionId
})
} catch (error) {
if (error instanceof Error && error.message === 'terminal_pane_owner_changed') {
throw error
}
console.error('[pty] failed to persist PTY binding after attach:', error)
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
spawnTiming.log(result.id, {
daemon: isDaemonHostSpawn,
reattach: result.isReattach ?? false
@ -6303,7 +6380,8 @@ export function registerPtyHandlers(
store &&
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
validatedLeafId !== null
validatedLeafId !== null &&
!stablePaneBindingPersisted
) {
try {
const binding = {

View File

@ -9033,6 +9033,221 @@ describe('Store', () => {
).toBeUndefined()
})
it('reconciles only the incarnation of an unchanged durable PTY binding', async () => {
const store = await createStore()
const paneKey = `tab1:${TEST_LEAF_1}`
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
})
expect(
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-1',
incarnationId: 'inc-live',
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
})
).toBe(true)
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe('inc-live')
const reloaded = await createStore()
expect(reloaded.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-live'
)
})
it('rejects competing PTY and incarnation changes during reconciliation', async () => {
const store = await createStore()
const paneKey = `tab1:${TEST_LEAF_1}`
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-current' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-current' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-current' }
})
for (const competing of [
{ ptyId: 'pty-replaced', expectedIncarnationId: 'inc-current' },
{ ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' }
]) {
expect(
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: competing.ptyId,
incarnationId: 'inc-live',
expectedBinding: {
ptyId: competing.ptyId,
incarnationId: competing.expectedIncarnationId
}
})
).toBe(false)
}
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-current'
)
})
it('reconciles only the requested execution-host partition', async () => {
const store = await createStore()
const paneKey = `tab1:${TEST_LEAF_1}`
const session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf' as const, leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
}
store.setWorkspaceSession(structuredClone(session), 'local')
store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1')
expect(
store.persistPtyBinding(
{
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-1',
incarnationId: 'inc-remote-live',
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
},
'ssh:ssh-1'
)
).toBe(true)
expect(store.getWorkspaceSession('local').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-stale'
)
expect(store.getWorkspaceSession('ssh:ssh-1').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-remote-live'
)
})
it.each([
{ label: 'local', hostId: undefined },
{ label: 'SSH', hostId: 'ssh:ssh-1' }
])(
'preserves a reconciled incarnation across a renderer snapshot ($label)',
async ({ hostId }) => {
const store = await createStore()
const paneKey = `tab1:${TEST_LEAF_1}`
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
},
hostId
)
expect(
store.persistPtyBinding(
{
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-1',
incarnationId: 'inc-live',
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
},
hostId
)
).toBe(true)
const rendererSnapshot = structuredClone(store.getWorkspaceSession(hostId))
delete rendererSnapshot.terminalPtyIncarnationsByPaneKey
delete rendererSnapshot.terminalTopologyRevisionByRepoId
store.setWorkspaceSession(rendererSnapshot, hostId)
expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-live'
)
const reloaded = await createStore()
expect(reloaded.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-live'
)
}
)
it('rolls back incarnation reconciliation when the durability barrier fails', async () => {
const store = await createStore()
const paneKey = `tab1:${TEST_LEAF_1}`
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
})
vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => {
throw new Error('disk full')
})
expect(() =>
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-1',
incarnationId: 'inc-live',
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
})
).toThrow('disk full')
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-stale'
)
})
it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => {
const store = await createStore()
store.setWorkspaceSession({

View File

@ -6621,11 +6621,26 @@ export class Store {
ptyId: string
incarnationId?: string
startupCwd?: string
expectedBinding?: { ptyId: string; incarnationId?: string }
},
hostId?: string | null
): void {
): boolean {
const resolvedHostId = this.resolveHostId(hostId)
const session = this.getWorkspaceSession(resolvedHostId)
const paneKey = `${args.tabId}:${args.leafId}`
if (args.expectedBinding) {
const tab = session.tabsByWorktree?.[args.worktreeId]?.find(
(candidate) => candidate.id === args.tabId && candidate.worktreeId === args.worktreeId
)
const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId]
if (
!tab ||
boundPtyId !== args.expectedBinding.ptyId ||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId
) {
return false
}
}
if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) {
this.state.workspaceSessionsByHostId = {
...this.state.workspaceSessionsByHostId,
@ -6633,15 +6648,17 @@ export class Store {
}
}
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
const paneKey = `${args.tabId}:${args.leafId}`
const reconciledIncarnation =
args.expectedBinding !== undefined &&
args.incarnationId !== args.expectedBinding.incarnationId
let terminalMembershipChanged = false
const advanceTopologyAfterMembershipChange = (): void => {
const advanceTopologyFence = (): void => {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
if (!terminalMembershipChanged || currentRevision <= 0) {
if (!reconciledIncarnation && (!terminalMembershipChanged || currentRevision <= 0)) {
return
}
// Why: a real host-admitted spawn after a retirement must be distinguishable from a stale renderer replay.
// Why: host-admitted membership or incarnation changes must outrank a stale renderer replay.
session.terminalTopologyRevisionByRepoId = {
...session.terminalTopologyRevisionByRepoId,
[repoId]: currentRevision + 1
@ -6696,14 +6713,14 @@ export class Store {
}
if (!isTerminalLeafId(args.leafId)) {
// Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration.
advanceTopologyAfterMembershipChange()
advanceTopologyFence()
try {
this.flushOrThrow()
} catch (err) {
restoreSession()
throw err
}
return
return true
}
const layout = session.terminalLayoutsByTabId?.[args.tabId]
if (layout) {
@ -6744,13 +6761,14 @@ export class Store {
}
}
}
advanceTopologyAfterMembershipChange()
advanceTopologyFence()
try {
this.flushOrThrow()
} catch (err) {
restoreSession()
throw err
}
return true
}
// ── SSH Targets ────────────────────────────────────────────────────

View File

@ -38,6 +38,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { SessionNotFoundError } from '../daemon/daemon-errors'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { addWslEnvKeys } from '../wsl-env'
@ -546,7 +547,7 @@ export class LocalPtyProvider implements IPtyProvider {
}
}
if (args.attachOnly) {
throw new Error(`Session not found: ${args.sessionId ?? ''}`)
throw new SessionNotFoundError(args.sessionId ?? '')
}
const id = allocatePtyId(reattachId ?? undefined)
const incarnationId = randomUUID()

View File

@ -64,6 +64,10 @@ export type PtySpawnOptions = {
isNewSession?: boolean
/** Attach the named session atomically or fail without creating a process. */
attachOnly?: boolean
/** Exact persisted owner expected by an attach-only routing decision. */
expectedIncarnationId?: PtyIncarnationId
/** True when runtime state makes the expected incarnation a hard attach fence. */
expectedIncarnationIsAuthoritative?: boolean
/** Why: allows the renderer to request a specific shell for a single new
* terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without
* changing the user's persistent default shell setting. Only consulted on

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
humanizeTerminalError,
isSshReconnectOwnedTerminalError,
shouldOfferDaemonRestart,
stripSshReconnectOwnedErrorLines
@ -36,6 +37,24 @@ describe('isSshReconnectOwnedTerminalError', () => {
})
})
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')
})
it('humanizes an IPC-wrapped pane-owner-unverified error', () => {
const wrapped =
"Error invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified"
expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified')
})
it('leaves other errors untouched', () => {
expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.')
})
})
describe('stripSshReconnectOwnedErrorLines', () => {
it('clears an error that is only SSH reconnect text', () => {
expect(stripSshReconnectOwnedErrorLines(SSH_FAILURE)).toBeNull()

View File

@ -12,6 +12,8 @@ const STALE_DAEMON_CWD_MARKERS = [
"Daemon's working directory is gone",
'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'
function isSshError(error: string): boolean {
return error.startsWith(SSH_PREFIX) || error.includes(SSH_RELAY_LOST_MARKER)
@ -42,6 +44,20 @@ export function shouldOfferDaemonRestart(error: string): boolean {
)
}
/** Swaps the raw pane-owner-unverified code for copy a user can act on. */
export function humanizeTerminalError(error: string): string {
if (!error.includes(PANE_OWNER_UNVERIFIED_MARKER)) {
return error
}
return error.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."
)
)
}
export function TerminalErrorToast({
error,
onDismiss,
@ -53,6 +69,7 @@ export function TerminalErrorToast({
}): React.JSX.Element {
const ssh = isSshError(error)
const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error)
const displayError = humanizeTerminalError(error)
return (
<div
@ -75,7 +92,7 @@ export function TerminalErrorToast({
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<span style={{ minWidth: 0 }}>
{error}
{displayError}
{showDaemonRestart ? (
<>
{'\n'}

View File

@ -2801,7 +2801,8 @@
"e4aa243f8c": "Restart daemon",
"a7e2fd2699": "file an issue",
"5c8ce20be6": "If this persists, please",
"cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state."
"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."
},
"TerminalPane": {
"ac112e9036": "Remove title",