diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index b84b1b6025d..abfedf244ea 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10293,7 +10293,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/pty-process-liveness-broker.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts --fileParallelism=false --maxWorkers=1 src/main/runtime/restored-agent-authority-resolver.test.ts src/main/runtime/pty-process-liveness-broker.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", "cd mobile && pnpm exec vitest run src/worktree/agent-row-display.test.ts src/transport/stable-logical-rpc-client.test.ts", "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1", @@ -10305,6 +10305,7 @@ "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/restored-agent-authority-resolver.test.ts", "src/main/runtime/pty-process-liveness-broker.test.ts", "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts", "src/cli/handlers/orchestration.test.ts", @@ -10322,7 +10323,8 @@ "src/main/ssh/ssh-remote-orca-cli.test.ts", "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts", - "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts" + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts", + "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts" ], "assertionRefs": [ { @@ -10349,6 +10351,15 @@ "ambiguous, incomplete, or mismatched legacy identities remain fenced and deferred" ] }, + { + "file": "src/main/runtime/restored-agent-authority-resolver.test.ts", + "assertions": [ + "one restored hook cannot borrow a replacement PTY incarnation or another execution host", + "conflicting current and persisted bindings remain unverifiable", + "incarnation identity survives local lifecycle-generation changes", + "retired hook commitments are released without unbounded growth" + ] + }, { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ @@ -10359,7 +10370,10 @@ "a connected live PTY remains inventoried when its user-visible tab is missing", "legacy clients retain the exact restored-row suppression", "inspection timeout, transport loss, missing bindings, and stale incarnations remain unverifiable", + "a handle-qualified cold binding remains visible when owning inventory loses contact", "terminal_gone is the only inspection rejection that retires the exact restored row", + "fresh exact inventory cannot be overwritten by older cached terminal_gone evidence after later contact loss", + "slow foreground inspection cannot manufacture agent exit or certify a replacement incarnation", "sequential inventory clients reuse bounded process evidence instead of polling each restored row" ] }, @@ -10508,10 +10522,10 @@ "date": "2026-08-23", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/pty-process-liveness-broker.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts --fileParallelism=false --maxWorkers=1 src/main/runtime/restored-agent-authority-resolver.test.ts src/main/runtime/pty-process-liveness-broker.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", "result": "passed", - "durationSeconds": 15.33, - "summary": "Eight focused runtime and wire files passed 1,273 tests with one skip, including bounded inspection timeout, exact terminal_gone absence, cold persisted binding, incarnation and hook replacement races, sequential-client call counts, live/unverifiable/exited/hidden-tab/legacy projection, and encrypted desktop/CLI capability advertisement. Mobile typecheck and 25 focused mobile tests passed separately." + "durationSeconds": 49.44, + "summary": "Ten focused runtime and wire files passed 1,292 tests with one skip, including bounded inspection timeout, exact terminal_gone absence, three-poll fresh-presence reconciliation, cold handle-qualified contact loss, incarnation/host/hook replacement fencing, slow foreground completion, callback isolation, sequential-client call counts, live/unverifiable/exited/hidden-tab/legacy projection, encrypted desktop/CLI capability advertisement, and cross-version terminal wire compatibility. Full mobile typecheck and 3,674 tests passed separately." }, { "date": "2026-08-13", diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 969bb7fa238..8b4795a6682 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -9892,6 +9892,45 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).toHaveBeenCalledTimes(2) }) + it('does not manufacture an agent exit when foreground inspection is slow', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + let resolveProcess!: (process: string) => void + const getForegroundProcess = vi.fn( + () => + new Promise((resolve) => { + resolveProcess = resolve + }) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 101) + await vi.advanceTimersByTimeAsync(10_000) + + expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ + kind: 'agent-exited' + }) + runtime.onPtySpawned('pty-1', 'replacement-incarnation', { + awaitsRegistration: false + }) + resolveProcess('zsh') + await vi.runAllTimersAsync() + expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ + kind: 'agent-exited' + }) + } finally { + vi.useRealTimers() + } + }) + it('does not confirm an agent exit from a foreground read predating its title', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -9921,7 +9960,7 @@ describe('OrcaRuntimeService', () => { ) }) - it('treats synchronous foreground read failures as unavailable', async () => { + it('does not turn synchronous foreground read failures into agent exits', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') @@ -9936,9 +9975,9 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) - await vi.waitFor(() => - expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) - ) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) + await new Promise((resolve) => setImmediate(resolve)) + expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ kind: 'agent-exited' }) expect(getForegroundProcess).toHaveBeenCalledOnce() }) @@ -37846,6 +37885,58 @@ describe('OrcaRuntimeService', () => { expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('persisted-pty') }) + it('retains a handle-qualified cold binding when owning inventory loses contact', async () => { + const paneKey = makePaneKey('host-tab', HEADLESS_LEAF_ID) + const incarnationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const session = makeWorkspaceSessionWithHeadlessTerminal({ + terminalPtyIncarnationsByPaneKey: { [paneKey]: incarnationId } + }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const now = Date.now() + const inspectProcess = vi.fn(async () => ({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true as const + })) + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'host-tab', + terminalHandle: 'term_restored', + state: 'working', + prompt: 'await reconnect', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100, + restoredUnconfirmed: true + } + ] + }) + runtime.setPtyController({ + write: vi.fn(() => true), + kill: vi.fn(() => true), + getForegroundProcess: vi.fn(async () => null), + inspectProcess, + listProcesses: vi.fn(async () => { + throw new Error('socket_closed') + }) + }) + + const { worktrees } = await runtime.getWorktreePs(undefined, true, true) + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents).toEqual([ + expect.objectContaining({ + paneKey, + restoredUnconfirmed: true, + agentLiveness: 'unverifiable' + }) + ]) + expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('persisted-pty') + }) + it('keeps conflicting persisted and current PTY bindings unverifiable', async () => { const paneKey = makePaneKey('host-tab', HEADLESS_LEAF_ID) const session = makeWorkspaceSessionWithHeadlessTerminal({ @@ -37941,6 +38032,55 @@ describe('OrcaRuntimeService', () => { ) }) + it('does not use a replacement incarnation to contradict exact terminal absence', async () => { + const paneKey = makePaneKey('host-tab', HEADLESS_LEAF_ID) + const originalIncarnation = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const session = makeWorkspaceSessionWithHeadlessTerminal({ + terminalPtyIncarnationsByPaneKey: { [paneKey]: originalIncarnation } + }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'host-tab', + state: 'working', + prompt: 'old incarnation completed', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100, + restoredUnconfirmed: true + } + ] + }) + runtime.setPtyController({ + write: vi.fn(() => true), + kill: vi.fn(() => true), + getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => { + throw new Error('terminal_gone') + }), + listProcesses: vi.fn(async () => [ + { + id: 'persisted-pty', + cwd: TEST_WORKTREE_PATH, + title: 'Codex', + incarnationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + terminalHandle: 'term_replacement' + } + ]) + }) + + const { worktrees } = await runtime.getWorktreePs(undefined, true, true) + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents).toEqual( + [] + ) + }) + it('keeps cached terminal_gone unverifiable when owning inventory re-observes the PTY', async () => { const leafId = '33333333-3333-4333-8333-333333333333' const paneKey = `tab-1:${leafId}` @@ -37952,7 +38092,7 @@ describe('OrcaRuntimeService', () => { const listProcesses = vi .fn() .mockResolvedValueOnce([]) - .mockResolvedValue([ + .mockResolvedValueOnce([ { id: 'pty-1', cwd: TEST_WORKTREE_PATH, @@ -37961,6 +38101,7 @@ describe('OrcaRuntimeService', () => { terminalHandle: 'term_restored' } ]) + .mockRejectedValueOnce(new Error('socket_closed')) const runtime = new OrcaRuntimeService(store, undefined, { getAgentStatusSnapshot: () => [ { @@ -38013,6 +38154,7 @@ describe('OrcaRuntimeService', () => { const first = await runtime.getWorktreePs(undefined, true, true) const second = await runtime.getWorktreePs(undefined, true, true) + const third = await runtime.getWorktreePs(undefined, true, true) expect( first.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents @@ -38026,6 +38168,15 @@ describe('OrcaRuntimeService', () => { agentLiveness: 'unverifiable' }) ]) + expect( + third.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([ + expect.objectContaining({ + paneKey, + restoredUnconfirmed: true, + agentLiveness: 'unverifiable' + }) + ]) expect(inspectProcess).toHaveBeenCalledOnce() }) @@ -38179,6 +38330,76 @@ describe('OrcaRuntimeService', () => { ]) }) + it('does not borrow local PTY evidence for a restored SSH hook', async () => { + const leafId = '33333333-3333-4333-8333-333333333333' + const paneKey = `tab-1:${leafId}` + const now = Date.now() + const inspectProcess = vi.fn(async () => ({ + foregroundProcess: 'codex', + hasChildProcesses: true + })) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + prompt: 'remote hook', + agentType: 'codex', + connectionId: 'ssh-1', + receivedAt: now, + stateStartedAt: now - 100, + restoredUnconfirmed: true + } + ] + }) + runtime.setPtyController({ + write: vi.fn(() => true), + kill: vi.fn(() => true), + getForegroundProcess: vi.fn(async () => 'codex'), + inspectProcess, + listProcesses: vi.fn(async () => []) + }) + runtime.registerPty('pty-1', TEST_WORKTREE_ID, null, { + tabId: 'tab-1', + leafId, + incarnationId: 'incarnation-a' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs(undefined, true, true) + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents).toEqual([ + expect.objectContaining({ + paneKey, + restoredUnconfirmed: true, + agentLiveness: 'unverifiable' + }) + ]) + expect(inspectProcess).not.toHaveBeenCalled() + }) + it('fences a restored-agent inspection across PTY reincarnation', async () => { const leafId = '33333333-3333-4333-8333-333333333333' const paneKey = `tab-1:${leafId}` @@ -38256,6 +38477,18 @@ describe('OrcaRuntimeService', () => { agentLiveness: 'unverifiable' }) ]) + const afterReplacement = await runtime.getWorktreePs(undefined, true, true) + expect( + afterReplacement.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + ?.agents + ).toEqual([ + expect.objectContaining({ + paneKey, + restoredUnconfirmed: true, + agentLiveness: 'unverifiable' + }) + ]) + expect(inspectProcess).toHaveBeenCalledOnce() }) it('does not publish a restored hook snapshot replaced during inspection', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0fa82878195..162b848b04f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -661,6 +661,12 @@ import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { PtyProcessLivenessBroker } from './pty-process-liveness-broker' +import { + RestoredAgentAuthorityResolver, + sameRestoredAgentProcessAuthority, + type RestoredAgentAuthorityBinding, + type RestoredAgentAuthorityHook +} from './restored-agent-authority-resolver' import { buildHeadlessTerminalSplitLayout, countTerminalLayoutLeaves, @@ -1628,6 +1634,8 @@ type PtyForegroundAgentRefresh = { type PtyForegroundProcessRead = { controller: RuntimePtyController + identity: string + status: RestoredAgentLiveness process: string | null available: boolean } @@ -1640,13 +1648,6 @@ type RestoredAgentLivenessResolution = { hasExactBinding: boolean } -type RestoredAgentPtyBinding = { - ptyId: string - incarnationId: PtyIncarnationId | null - lifecycleGeneration: number - source: 'current' | 'persisted' -} - function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { @@ -1992,6 +1993,7 @@ type PtyControllerInventory = Readonly<{ // Why: livePtyIds is worktree-scoped when a target is given; absence proofs // must consult the unscoped inventory or a misattributed live PTY reads as dead. allLivePtyIds: ReadonlySet + incarnationIdByPtyId: ReadonlyMap terminalIdentityByPtyId: ReadonlyMap queriedHostIds: ReadonlySet }> @@ -3186,6 +3188,7 @@ export class OrcaRuntimeService { console.warn(`[runtime] PTY process inspection failed for ${ptyId}`, error) } }) + private readonly restoredAgentAuthority = new RestoredAgentAuthorityResolver() private ptyDelayedForegroundSnapshotTitleObservations = new Map() // Why a set and not a timer: the intent is retired by the exit it explains, or // by the next lifecycle generation on that id (advancePtyLifecycleGeneration), @@ -19061,7 +19064,7 @@ export class OrcaRuntimeService { private readPtyForegroundProcessFromController( ptyId: string, afterTitleObservation = 0, - options: { reuseSettled?: boolean } = {} + options: { reuseSettled?: boolean; waitForSettlement?: boolean } = {} ): Promise | null { const controller = this.ptyController if (!controller) { @@ -19074,10 +19077,13 @@ export class OrcaRuntimeService { ptyId, identity, freshness: afterTitleObservation, - reuseSettled: options.reuseSettled + reuseSettled: options.reuseSettled, + waitForSettlement: options.waitForSettlement }) .then((evidence) => ({ controller, + identity, + status: evidence.status, process: evidence.status === 'live' ? evidence.foregroundProcess : null, available: evidence.status === 'live' })) @@ -19096,15 +19102,18 @@ export class OrcaRuntimeService { const foregroundRead = this.readPtyForegroundProcessFromController( ptyId, titleObservedAt ?? 0, - { reuseSettled: false } + { reuseSettled: false, waitForSettlement: true } ) if (!pty?.connected || !foregroundRead) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) return } void foregroundRead.then((result) => { const current = this.ptysById.get(ptyId) - if (current !== pty || !current.connected) { + if ( + current !== pty || + !current.connected || + result.identity !== this.getPtyProcessEvidenceIdentity(ptyId) + ) { return } if (current.lastOscTitleAt !== titleObservedAt && current.lastAgentStatus !== null) { @@ -19133,6 +19142,9 @@ export class OrcaRuntimeService { } return } + if (result.status === 'unverifiable') { + return + } this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) }) } @@ -19249,7 +19261,11 @@ export class OrcaRuntimeService { return false } const result = await foregroundRead - if (result.controller !== this.ptyController || !result.available) { + if ( + result.controller !== this.ptyController || + result.identity !== this.getPtyProcessEvidenceIdentity(ptyId) || + !result.available + ) { return false } const foregroundProcess = result.process @@ -19980,7 +19996,9 @@ export class OrcaRuntimeService { const ptyProcessEvidenceDeadline = Date.now() + PTY_CONTROLLER_LIST_TIMEOUT_MS // Why: worktree.ps backs the mobile sidebar, so it must use the same // host-owned imported-worktree visibility gate as worktree.list/desktop. - const freshPtyLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + const freshPtyInventory = + await this.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + const freshPtyLiveness = freshPtyInventory?.livePtyIds ?? null const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId const summaries = new Map() @@ -20337,7 +20355,7 @@ export class OrcaRuntimeService { missingRuntimeWorktreeIds, mirroredWorktreeIdByTabId, connectedPtyEvidence, - freshPtyLiveness, + freshPtyInventory, restoredAgentPresenceSupported, ptyProcessEvidenceDeadline ) @@ -20364,7 +20382,7 @@ export class OrcaRuntimeService { paneKeys: ReadonlySet ptyIds: ReadonlySet }, - freshPtyLiveness: ReadonlySet | null, + freshPtyInventory: PtyControllerInventory | null, restoredAgentPresenceSupported: boolean, ptyProcessEvidenceDeadline: number ): Promise { @@ -20417,7 +20435,7 @@ export class OrcaRuntimeService { ? await this.resolveRestoredAgentLiveness( hookEntriesAtInspection, ptyProcessEvidenceDeadline, - freshPtyLiveness + freshPtyInventory ) : new Map() const hookEntries = restoredAgentPresenceSupported @@ -20570,33 +20588,28 @@ export class OrcaRuntimeService { private async resolveRestoredAgentLiveness( entries: readonly AgentStatusIpcPayload[], deadline: number, - freshPtyLiveness: ReadonlySet | null + freshPtyInventory: PtyControllerInventory | null ): Promise> { const restoredEntries = entries.filter((entry) => entry.restoredUnconfirmed === true) + this.restoredAgentAuthority.retain( + new Set(restoredEntries.map((entry) => restoredAgentHookIdentity(entry))) + ) const resolved = await Promise.all( restoredEntries.map(async (entry): Promise<[string, RestoredAgentLivenessResolution]> => { const hookIdentity = restoredAgentHookIdentity(entry) - const binding = this.getRestoredAgentPtyBinding(entry) + const authority = this.getRestoredAgentAuthority(entry, freshPtyInventory) + const binding = authority.binding const result = ( liveness: RestoredAgentLiveness, - hasExactBinding = binding !== null + hasExactBinding = authority.hasExactBinding ): [string, RestoredAgentLivenessResolution] => [ entry.paneKey, { liveness, hookIdentity, hasExactBinding } ] if (!binding) { - return result('unverifiable', false) - } - const currentAtStart = this.getPtyRecordForPaneKey(entry.paneKey) - if ( - currentAtStart && - (currentAtStart.ptyId !== binding.ptyId || - (binding.incarnationId !== null && - currentAtStart.incarnationId !== binding.incarnationId)) - ) { return result('unverifiable') } - if (currentAtStart && this.isPtyKnownExited(currentAtStart.ptyId)) { + if (this.isPtyKnownExited(binding.ptyId)) { return result('exited') } const controller = this.ptyController @@ -20608,17 +20621,20 @@ export class OrcaRuntimeService { ptyId: binding.ptyId, identity: binding.incarnationId ?? `runtime:${this.runtimeId}:${binding.lifecycleGeneration}`, - deadline + deadline, + owningInventoryObservedPty: this.inventoryProvesRestoredBindingPresent( + freshPtyInventory, + binding + ) }) + const currentAuthority = this.getRestoredAgentAuthority(entry, freshPtyInventory).binding if ( controller !== this.ptyController || - !this.isRestoredAgentPtyBindingCurrent(entry, binding) + !currentAuthority || + !sameRestoredAgentProcessAuthority(currentAuthority, binding) ) { return result('unverifiable') } - if (evidence.status === 'exited' && freshPtyLiveness?.has(binding.ptyId)) { - return result('unverifiable') - } if (evidence.status !== 'live') { return result(evidence.status) } @@ -20641,33 +20657,26 @@ export class OrcaRuntimeService { return new Map(resolved) } - private getRestoredAgentPtyBinding(entry: AgentStatusIpcPayload): RestoredAgentPtyBinding | null { - const persisted = this.getPersistedRestoredAgentPtyBinding(entry) - const current = this.getPtyRecordForPaneKey(entry.paneKey) - if (persisted && current) { - return current.ptyId === persisted.ptyId && current.incarnationId === persisted.incarnationId - ? { - ptyId: current.ptyId, - incarnationId: current.incarnationId, - lifecycleGeneration: this.getPtyLifecycleGeneration(current.ptyId), - source: 'current' - } - : persisted + private getRestoredAgentAuthority( + entry: AgentStatusIpcPayload, + inventory: PtyControllerInventory | null + ) { + const hook = this.getRestoredAgentAuthorityHook(entry) + if (!hook) { + return { binding: null, hasExactBinding: false } } - if (current) { - return { - ptyId: current.ptyId, - incarnationId: current.incarnationId, - lifecycleGeneration: this.getPtyLifecycleGeneration(current.ptyId), - source: 'current' - } - } - return persisted + return this.restoredAgentAuthority.resolve({ + hook, + current: this.getCurrentRestoredAgentBinding(entry, hook, inventory), + persisted: this.getPersistedRestoredAgentPtyBinding(entry, hook, inventory) + }) } private getPersistedRestoredAgentPtyBinding( - entry: AgentStatusIpcPayload - ): RestoredAgentPtyBinding | null { + entry: AgentStatusIpcPayload, + hook: RestoredAgentAuthorityHook, + inventory: PtyControllerInventory | null + ): RestoredAgentAuthorityBinding | null { if (!entry.worktreeId) { return null } @@ -20677,34 +20686,156 @@ export class OrcaRuntimeService { binding.paneKey === entry.paneKey && runtimeWorktreeIdsEqual(binding.worktreeId, entry.worktreeId) ) { + const controllerIdentity = inventory?.terminalIdentityByPtyId.get(ptyId) + const encodedHost = getPtyExecutionHost(ptyId) + if ( + encodedHost === 'foreign' || + (encodedHost !== null && encodedHost !== hook.hostKey) || + (hook.hostKey.startsWith('wsl:') && + controllerIdentity?.wslDistro?.toLowerCase() !== hook.hostKey.slice(4)) + ) { + return null + } return { ptyId, incarnationId: binding.incarnationId, lifecycleGeneration: this.getPtyLifecycleGeneration(ptyId), - source: 'persisted' + source: 'persisted', + paneKey: binding.paneKey, + worktreeKey: hook.worktreeKey, + hostKey: hook.hostKey, + ...((controllerIdentity?.handle ?? hook.terminalHandle) + ? { terminalHandle: controllerIdentity?.handle ?? hook.terminalHandle } + : {}) } } } return null } - private isRestoredAgentPtyBindingCurrent( + private getCurrentRestoredAgentBinding( entry: AgentStatusIpcPayload, - binding: RestoredAgentPtyBinding - ): boolean { - if (this.getPtyLifecycleGeneration(binding.ptyId) !== binding.lifecycleGeneration) { + hook: RestoredAgentAuthorityHook, + inventory: PtyControllerInventory | null + ): RestoredAgentAuthorityBinding | null { + const current = this.getExactPtyRecordForPaneKey(entry.paneKey) + if ( + !current || + !runtimeWorktreeIdsEqual(current.worktreeId, entry.worktreeId ?? '') || + !this.ptyMatchesRestoredAgentHost(current, hook.hostKey) + ) { + return null + } + const controllerIdentity = inventory?.terminalIdentityByPtyId.get(current.ptyId) + return { + ptyId: current.ptyId, + incarnationId: current.incarnationId, + lifecycleGeneration: this.getPtyLifecycleGeneration(current.ptyId), + source: 'current', + paneKey: entry.paneKey, + worktreeKey: hook.worktreeKey, + hostKey: hook.hostKey, + ...((controllerIdentity?.handle ?? + this.handleByPtyId.get(current.ptyId) ?? + hook.terminalHandle) + ? { + terminalHandle: + controllerIdentity?.handle ?? + this.handleByPtyId.get(current.ptyId) ?? + hook.terminalHandle + } + : {}) + } + } + + private getRestoredAgentAuthorityHook( + entry: AgentStatusIpcPayload + ): RestoredAgentAuthorityHook | null { + if (!entry.worktreeId) { + return null + } + const worktreeHost = this.tryGetWorkspaceSessionHostIdForWorktree(entry.worktreeId) + if (!worktreeHost) { + return null + } + let hostKey: string + if (isWslHookRelayConnectionId(entry.connectionId)) { + if (worktreeHost !== LOCAL_EXECUTION_HOST_ID) { + return null + } + hostKey = entry.connectionId!.toLowerCase() + } else if (entry.connectionId) { + hostKey = toSshExecutionHostId(entry.connectionId) + if (hostKey !== worktreeHost) { + return null + } + } else { + hostKey = worktreeHost + } + return { + identity: restoredAgentHookIdentity(entry), + paneKey: entry.paneKey, + worktreeKey: runtimeWorktreeIdentityKey(entry.worktreeId), + hostKey, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) + } + } + + private getExactPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { + const parsed = parsePaneKey(paneKey) + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : undefined + const candidates = new Set() + if (leaf?.ptyId) { + const pty = this.ptysById.get(leaf.ptyId) + if (pty) { + candidates.add(pty) + } + } + for (const pty of this.ptysById.values()) { + if (pty.paneKey === paneKey) { + candidates.add(pty) + } + } + const connected = [...candidates].filter((pty) => pty.connected) + return connected.length === 1 + ? connected[0]! + : candidates.size === 1 + ? [...candidates][0]! + : null + } + + private ptyMatchesRestoredAgentHost(pty: RuntimePtyWorktreeRecord, hostKey: string): boolean { + const encodedHost = getPtyExecutionHost(pty.ptyId) + if (encodedHost === 'foreign') { return false } - const current = this.getPtyRecordForPaneKey(entry.paneKey) - if (binding.source === 'current') { - return current?.ptyId === binding.ptyId && current.incarnationId === binding.incarnationId + if (encodedHost !== null) { + return encodedHost === hostKey } - const persisted = this.getPersistedRestoredAgentPtyBinding(entry) - return ( - current === null && - persisted?.ptyId === binding.ptyId && - persisted.incarnationId === binding.incarnationId - ) + if (hostKey.startsWith('wsl:')) { + return ( + pty.connectionId === null && pty.wslDistro?.toLowerCase() === hostKey.slice(4).toLowerCase() + ) + } + const parsedHost = parseExecutionHostId(hostKey) + if (parsedHost?.kind === 'ssh') { + return pty.connectionId === parsedHost.targetId + } + return pty.connectionId === null && !pty.wslDistro + } + + private inventoryProvesRestoredBindingPresent( + inventory: PtyControllerInventory | null, + binding: RestoredAgentAuthorityBinding + ): boolean { + if (!inventory?.livePtyIds.has(binding.ptyId) || !binding.incarnationId) { + return false + } + if (inventory.incarnationIdByPtyId.get(binding.ptyId) !== binding.incarnationId) { + return false + } + const identity = inventory.terminalIdentityByPtyId.get(binding.ptyId) + return !binding.terminalHandle || identity?.handle === binding.terminalHandle } listRepos(): Repo[] { @@ -32691,6 +32822,7 @@ export class OrcaRuntimeService { return { livePtyIds: targetedLiveness, allLivePtyIds: targetedLiveness, + incarnationIdByPtyId: new Map(), terminalIdentityByPtyId: new Map(), queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID]) } @@ -32762,11 +32894,21 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value.processes const queriedHostIds = new Set(sessionsResult.value.hostIds) const controllerIdentityByPtyId = new Map() + const incarnationIdByPtyId = new Map() + const ambiguousIncarnationPtyIds = new Set() const ptyIdByControllerHandle = new Map() const ambiguousControllerPtyIds = new Set() for (const session of sessions) { const handle = session.terminalHandle?.trim() const incarnationId = session.incarnationId?.trim() + if (incarnationId && !ambiguousIncarnationPtyIds.has(session.id)) { + if (incarnationIdByPtyId.has(session.id)) { + incarnationIdByPtyId.delete(session.id) + ambiguousIncarnationPtyIds.add(session.id) + } else { + incarnationIdByPtyId.set(session.id, incarnationId) + } + } if (!handle?.startsWith('term_') || !incarnationId) { continue } @@ -32964,6 +33106,7 @@ export class OrcaRuntimeService { return { livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, allLivePtyIds, + incarnationIdByPtyId, terminalIdentityByPtyId: controllerIdentityByPtyId, queriedHostIds } diff --git a/src/main/runtime/pty-process-liveness-broker.test.ts b/src/main/runtime/pty-process-liveness-broker.test.ts index aa1f5b8e744..2b81dba9ae6 100644 --- a/src/main/runtime/pty-process-liveness-broker.test.ts +++ b/src/main/runtime/pty-process-liveness-broker.test.ts @@ -131,4 +131,94 @@ describe('PtyProcessLivenessBroker', () => { ).resolves.toMatchObject({ status: 'live', foregroundProcess: 'zsh' }) expect(inspectProcess).toHaveBeenCalledTimes(2) }) + + it('does not replay cached absence after owning inventory re-observes the PTY', async () => { + let now = 0 + const inspectProcess = vi + .fn() + .mockRejectedValueOnce(new Error('terminal_gone')) + .mockRejectedValueOnce(new Error('socket_closed')) + const source = { getForegroundProcess: vi.fn(async () => null), inspectProcess } + const broker = new PtyProcessLivenessBroker({ + timeoutMs: 100, + unavailableBackoffBaseMs: 100, + now: () => now + }) + + await expect( + broker.inspect({ source, ptyId: 'pty-1', identity: 'incarnation-a' }) + ).resolves.toEqual({ status: 'exited' }) + await expect( + broker.inspect({ + source, + ptyId: 'pty-1', + identity: 'incarnation-a', + owningInventoryObservedPty: true + }) + ).resolves.toMatchObject({ status: 'unverifiable' }) + now = 201 + await expect( + broker.inspect({ source, ptyId: 'pty-1', identity: 'incarnation-a' }) + ).resolves.toEqual({ status: 'unverifiable', reason: 'socket_closed' }) + }) + + it('lets completion-sensitive readers await a shared probe past the catalog timeout', async () => { + vi.useFakeTimers() + let resolveInspection!: (value: { + foregroundProcess: string + hasChildProcesses: boolean + }) => void + const inspection = new Promise<{ foregroundProcess: string; hasChildProcesses: boolean }>( + (resolve) => { + resolveInspection = resolve + } + ) + const source = { + getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(() => inspection) + } + const broker = new PtyProcessLivenessBroker({ timeoutMs: 100 }) + + const catalog = broker.inspect({ source, ptyId: 'pty-1', identity: 'incarnation-a' }) + await vi.advanceTimersByTimeAsync(100) + await expect(catalog).resolves.toMatchObject({ status: 'unverifiable' }) + const completion = broker.inspect({ + source, + ptyId: 'pty-1', + identity: 'incarnation-a', + waitForSettlement: true, + reuseSettled: false + }) + let settled = false + void completion.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(10_000) + expect(settled).toBe(false) + + resolveInspection({ foregroundProcess: 'codex', hasChildProcesses: true }) + await expect(completion).resolves.toMatchObject({ status: 'live', foregroundProcess: 'codex' }) + }) + + it('contains failures from diagnostic observers', async () => { + const broker = new PtyProcessLivenessBroker({ + timeoutMs: 100, + onInspectionError: () => { + throw new Error('logger failed') + } + }) + + await expect( + broker.inspect({ + source: { + getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => { + throw new Error('socket_closed') + }) + }, + ptyId: 'pty-1', + identity: 'incarnation-a' + }) + ).resolves.toEqual({ status: 'unverifiable', reason: 'socket_closed' }) + }) }) diff --git a/src/main/runtime/pty-process-liveness-broker.ts b/src/main/runtime/pty-process-liveness-broker.ts index ec6a6d38268..75a5a487c9d 100644 --- a/src/main/runtime/pty-process-liveness-broker.ts +++ b/src/main/runtime/pty-process-liveness-broker.ts @@ -21,6 +21,7 @@ type PtyProcessEvidenceEntry = { source: PtyProcessInspectionSource identity: string freshness: number + owningInventoryObservedPty: boolean failureCount: number evidence: PtyProcessLivenessEvidence | null expiresAt: number @@ -56,16 +57,30 @@ export class PtyProcessLivenessBroker { freshness?: number reuseSettled?: boolean deadline?: number + waitForSettlement?: boolean + owningInventoryObservedPty?: boolean }): Promise { const freshness = args.freshness ?? 0 - const waitMs = Math.max( - 0, - Math.min( - this.options.timeoutMs, - args.deadline === undefined ? this.options.timeoutMs : args.deadline - this.now() - ) - ) + const waitMs = args.waitForSettlement + ? null + : Math.max( + 0, + Math.min( + this.options.timeoutMs, + args.deadline === undefined ? this.options.timeoutMs : args.deadline - this.now() + ) + ) const existing = this.entries.get(args.ptyId) + if ( + existing?.source === args.source && + existing.identity === args.identity && + args.owningInventoryObservedPty === true + ) { + existing.owningInventoryObservedPty = true + if (existing.evidence?.status === 'exited') { + this.storeUnverifiable(existing, 'owning inventory re-observed PTY') + } + } if ( existing?.source === args.source && existing.identity === args.identity && @@ -90,7 +105,7 @@ export class PtyProcessLivenessBroker { existing.freshness >= freshness ) { if (existing.probe) { - return existing.timedOut + return !args.waitForSettlement && existing.timedOut ? Promise.resolve({ status: 'unverifiable', reason: 'process inspection timed out' }) : waitMs === 0 ? Promise.resolve({ @@ -115,6 +130,7 @@ export class PtyProcessLivenessBroker { source: args.source, identity: args.identity, freshness, + owningInventoryObservedPty: args.owningInventoryObservedPty === true, failureCount, evidence: null, expiresAt: 0, @@ -123,23 +139,30 @@ export class PtyProcessLivenessBroker { } const probe = this.runProbe(args.source, args.ptyId) .then((evidence) => { + const reconciled = + evidence.status === 'exited' && entry.owningInventoryObservedPty + ? { + status: 'unverifiable' as const, + reason: 'owning inventory re-observed PTY' + } + : evidence if (this.entries.get(args.ptyId) !== entry) { - return evidence + return reconciled } entry.probe = null entry.timedOut = false - entry.evidence = evidence - if (evidence.status === 'unverifiable') { + entry.evidence = reconciled + if (reconciled.status === 'unverifiable') { entry.failureCount += 1 entry.expiresAt = this.now() + this.unavailableBackoffMs(entry.failureCount) - } else if (evidence.status === 'live') { + } else if (reconciled.status === 'live') { entry.failureCount = 0 entry.expiresAt = this.now() + (this.options.liveTtlMs ?? DEFAULT_LIVE_TTL_MS) } else { entry.failureCount = 0 entry.expiresAt = Number.POSITIVE_INFINITY } - return evidence + return reconciled }) .catch((error): PtyProcessLivenessEvidence => { if (this.entries.get(args.ptyId) === entry) { @@ -149,7 +172,11 @@ export class PtyProcessLivenessBroker { entry.evidence = { status: 'unverifiable', reason: describeError(error) } entry.expiresAt = this.now() + this.unavailableBackoffMs(entry.failureCount) } - this.options.onInspectionError?.(args.ptyId, error) + try { + this.options.onInspectionError?.(args.ptyId, error) + } catch { + // Diagnostic observers cannot change the authority verdict. + } return { status: 'unverifiable', reason: describeError(error) } }) entry.probe = probe @@ -207,7 +234,7 @@ export class PtyProcessLivenessBroker { private waitForProbe( ptyId: string, entry: PtyProcessEvidenceEntry, - timeoutMs: number + timeoutMs: number | null ): Promise { const probe = entry.probe if (!probe) { @@ -215,6 +242,9 @@ export class PtyProcessLivenessBroker { entry.evidence ?? { status: 'unverifiable', reason: 'process inspection unavailable' } ) } + if (timeoutMs === null) { + return probe + } return new Promise((resolve) => { const timeout = setTimeout(() => { if (this.entries.get(ptyId) === entry && entry.probe === probe) { @@ -234,6 +264,12 @@ export class PtyProcessLivenessBroker { const max = this.options.unavailableBackoffMaxMs ?? DEFAULT_UNAVAILABLE_BACKOFF_MAX_MS return Math.min(max, base * 2 ** Math.max(0, failureCount - 1)) } + + private storeUnverifiable(entry: PtyProcessEvidenceEntry, reason: string): void { + entry.failureCount += 1 + entry.evidence = { status: 'unverifiable', reason } + entry.expiresAt = this.now() + this.unavailableBackoffMs(entry.failureCount) + } } function isTerminalGoneError(error: unknown): boolean { diff --git a/src/main/runtime/restored-agent-authority-resolver.test.ts b/src/main/runtime/restored-agent-authority-resolver.test.ts new file mode 100644 index 00000000000..e7125494ae0 --- /dev/null +++ b/src/main/runtime/restored-agent-authority-resolver.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + RestoredAgentAuthorityResolver, + type RestoredAgentAuthorityBinding, + type RestoredAgentAuthorityHook +} from './restored-agent-authority-resolver' + +const hook: RestoredAgentAuthorityHook = { + identity: 'hook-a', + paneKey: 'tab-a:leaf-a', + worktreeKey: 'repo-a\0/worktree-a', + hostKey: 'ssh:host-a' +} + +function binding( + overrides: Partial = {} +): RestoredAgentAuthorityBinding { + return { + ptyId: 'pty-a', + incarnationId: 'incarnation-a', + lifecycleGeneration: 1, + source: 'current', + paneKey: hook.paneKey, + worktreeKey: hook.worktreeKey, + hostKey: hook.hostKey, + ...overrides + } +} + +describe('RestoredAgentAuthorityResolver', () => { + it('rejects current process evidence from another execution host', () => { + const resolver = new RestoredAgentAuthorityResolver() + + expect( + resolver.resolve({ + hook, + current: binding({ hostKey: 'local' }), + persisted: null + }) + ).toEqual({ binding: null, hasExactBinding: false }) + }) + + it('keeps one hook fenced to its first process incarnation', () => { + const resolver = new RestoredAgentAuthorityResolver() + expect(resolver.resolve({ hook, current: binding(), persisted: null }).binding).toEqual( + binding() + ) + + expect( + resolver.resolve({ + hook, + current: binding({ incarnationId: 'incarnation-b' }), + persisted: null + }) + ).toEqual({ binding: null, hasExactBinding: false }) + }) + + it('treats lifecycle generations as fallback identity when incarnation exists', () => { + const resolver = new RestoredAgentAuthorityResolver() + resolver.resolve({ hook, current: binding(), persisted: null }) + + expect( + resolver.resolve({ + hook, + current: binding({ lifecycleGeneration: 2 }), + persisted: null + }).binding + ).toEqual(binding({ lifecycleGeneration: 2 })) + }) + + it('rejects disagreement between current and persisted bindings', () => { + const resolver = new RestoredAgentAuthorityResolver() + + expect( + resolver.resolve({ + hook, + current: binding({ incarnationId: 'incarnation-b' }), + persisted: binding({ source: 'persisted' }) + }) + ).toEqual({ binding: null, hasExactBinding: false }) + }) + + it('forgets commitments after their restored hook disappears', () => { + const resolver = new RestoredAgentAuthorityResolver() + resolver.resolve({ hook, current: binding(), persisted: null }) + resolver.retain(new Set()) + + expect( + resolver.resolve({ + hook, + current: binding({ incarnationId: 'incarnation-b' }), + persisted: null + }).binding + ).toEqual(binding({ incarnationId: 'incarnation-b' })) + }) +}) diff --git a/src/main/runtime/restored-agent-authority-resolver.ts b/src/main/runtime/restored-agent-authority-resolver.ts new file mode 100644 index 00000000000..14e4414680d --- /dev/null +++ b/src/main/runtime/restored-agent-authority-resolver.ts @@ -0,0 +1,89 @@ +export type RestoredAgentAuthorityHook = Readonly<{ + identity: string + paneKey: string + worktreeKey: string + hostKey: string + terminalHandle?: string +}> + +export type RestoredAgentAuthorityBinding = Readonly<{ + ptyId: string + incarnationId: string | null + lifecycleGeneration: number + source: 'current' | 'persisted' + paneKey: string + worktreeKey: string + hostKey: string + terminalHandle?: string +}> + +export type RestoredAgentAuthorityResolution = Readonly<{ + binding: RestoredAgentAuthorityBinding | null + hasExactBinding: boolean +}> + +export class RestoredAgentAuthorityResolver { + private readonly commitmentByHookIdentity = new Map() + + resolve(args: { + hook: RestoredAgentAuthorityHook + current: RestoredAgentAuthorityBinding | null + persisted: RestoredAgentAuthorityBinding | null + }): RestoredAgentAuthorityResolution { + const current = this.matchesHook(args.hook, args.current) ? args.current : null + const persisted = this.matchesHook(args.hook, args.persisted) ? args.persisted : null + if (current && persisted && !sameRestoredAgentProcessAuthority(current, persisted)) { + return { binding: null, hasExactBinding: false } + } + const binding = current ?? persisted + if (!binding) { + return { binding: null, hasExactBinding: false } + } + const commitment = processAuthorityKey(binding) + const existing = this.commitmentByHookIdentity.get(args.hook.identity) + if (existing && existing !== commitment) { + return { binding: null, hasExactBinding: false } + } + this.commitmentByHookIdentity.set(args.hook.identity, commitment) + return { binding, hasExactBinding: true } + } + + retain(hookIdentities: ReadonlySet): void { + for (const identity of this.commitmentByHookIdentity.keys()) { + if (!hookIdentities.has(identity)) { + this.commitmentByHookIdentity.delete(identity) + } + } + } + + private matchesHook( + hook: RestoredAgentAuthorityHook, + binding: RestoredAgentAuthorityBinding | null + ): binding is RestoredAgentAuthorityBinding { + return Boolean( + binding && + binding.paneKey === hook.paneKey && + binding.worktreeKey === hook.worktreeKey && + binding.hostKey === hook.hostKey && + (!hook.terminalHandle || binding.terminalHandle === hook.terminalHandle) + ) + } +} + +export function sameRestoredAgentProcessAuthority( + left: RestoredAgentAuthorityBinding, + right: RestoredAgentAuthorityBinding +): boolean { + return processAuthorityKey(left) === processAuthorityKey(right) +} + +function processAuthorityKey(binding: RestoredAgentAuthorityBinding): string { + return JSON.stringify([ + binding.hostKey, + binding.worktreeKey, + binding.paneKey, + binding.ptyId, + binding.incarnationId ?? `generation:${binding.lifecycleGeneration}`, + binding.terminalHandle ?? null + ]) +}