From 76029bf74ed41660cd495bad796ace27f865bde8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:12:08 -0700 Subject: [PATCH] fix(agent-status): status follows a terminal re-homed into a new tab after a restart (#33601) After an Orca restart with the PTY daemon alive, a surviving terminal placed in a different pane than its spawn ORCA_PANE_KEY now carries that key's agent status (prompt, session) to the live pane, using the daemon-reported spawn key and the manual pane-move mechanism; ambiguous keys (claimed by more than one live terminal) are left alone. Fixes #23753 Supersedes #31841 Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> --- .../server/server-authority-aliases.ts | 13 ++ .../runtime/exported-pane-key-follow.test.ts | 192 ++++++++++++++++++ .../runtime/runtime-terminal-state-records.ts | 2 + .../session-graph/session-graph-promotion.ts | 2 + .../session-graph/session-window-graph.ts | 1 + .../adopt-terminal-orphans-from-inventory.ts | 6 +- .../identity/exported-pane-key-follow.ts | 43 ++++ .../terminal/identity/pty-worktree-record.ts | 11 + .../pty-controller-inventory-refresh.ts | 2 + .../restored-terminal-placement.ts | 2 + 10 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 src/main/runtime/exported-pane-key-follow.test.ts create mode 100644 src/main/runtime/terminal/identity/exported-pane-key-follow.ts diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 64bcce71d5c..648bac993ec 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -136,6 +136,19 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut } } + /** Undoes the move `ptyId` made out of `physicalPaneKey`: its status and later posts land there again. */ + returnPaneAuthority(physicalPaneKey: string, ptyId: string): void { + const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) + if (alias?.ptyId !== ptyId) { + return + } + if (alias.stablePaneKey !== physicalPaneKey) { + this.transferPaneAuthority(alias.stablePaneKey, physicalPaneKey, ptyId) + } + this.legacyPaneKeyAliases.delete(physicalPaneKey) + this.notifyPaneKeyAliasPersistenceListener() + } + transferPaneAuthority( fromPaneKey: string, toPaneKey: string, diff --git a/src/main/runtime/exported-pane-key-follow.test.ts b/src/main/runtime/exported-pane-key-follow.test.ts new file mode 100644 index 00000000000..4c0d0795c6d --- /dev/null +++ b/src/main/runtime/exported-pane-key-follow.test.ts @@ -0,0 +1,192 @@ +// #23753: a terminal that survives a restart into a newly minted tab keeps posting hooks under the +// ORCA_PANE_KEY its env exported, so that key's agent status must follow it to the new pane. +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentHookServer } from '../agent-hooks/server' +import type { PtyProcessInfo } from '../providers/pty-process-info' + +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { TEST_WORKTREE_ID, TEST_WORKTREE_PATH, store } = + await import('./orca-runtime-test-fixtures.spec') + +// Fresh per test: the hook server is a process singleton. +let OLD_TAB = '' +let OLD_LEAF = '' +let NEW_TAB = '' +let NEW_LEAF = '' +let OLD_PANE_KEY = '' +let NEW_PANE_KEY = '' +const SURVIVOR = `${TEST_WORKTREE_ID}@@survivor` +const OTHER = `${TEST_WORKTREE_ID}@@other` + +function hookFromOldShell(state: 'working' | 'done'): void { + agentHookServer.ingestRemote( + { + paneKey: OLD_PANE_KEY, + tabId: OLD_TAB, + worktreeId: TEST_WORKTREE_ID, + payload: { state, prompt: 'fix the bug', agentType: 'claude' } + }, + null + ) +} + +function statesOn(paneKey: string): string[] { + return agentHookServer.getStatusSnapshotForPane(paneKey).map((row) => row.state) +} + +async function relaunch(listed: PtyProcessInfo[]) { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async () => false, + getForegroundProcess: async () => null, + listProcesses: async () => listed + }) + runtime.attachWindow(1) + await runtime.listTerminals() + return runtime +} + +function showSurvivorIn( + runtime: InstanceType, + tabId: string, + leafId: string +): void { + runtime.syncWindowGraph(1, { + tabs: [ + { tabId, worktreeId: TEST_WORKTREE_ID, title: 'Terminal', activeLeafId: leafId, layout: null } + ], + leaves: [{ tabId, worktreeId: TEST_WORKTREE_ID, leafId, paneRuntimeId: 1, ptyId: SURVIVOR }] + }) +} + +function showSurvivorInNewTab(runtime: InstanceType): void { + showSurvivorIn(runtime, NEW_TAB, NEW_LEAF) +} + +function listedSurvivor(spawnPaneKey?: string): PtyProcessInfo { + return { + id: SURVIVOR, + cwd: TEST_WORKTREE_PATH, + title: 'shell', + worktreeId: TEST_WORKTREE_ID, + ...(spawnPaneKey ? { paneKey: spawnPaneKey } : {}) + } +} + +describe('agent status of a terminal re-homed after a restart', () => { + beforeEach(() => { + ;[OLD_TAB, OLD_LEAF, NEW_TAB, NEW_LEAF] = [ + randomUUID(), + randomUUID(), + randomUUID(), + randomUUID() + ] + OLD_PANE_KEY = `${OLD_TAB}:${OLD_LEAF}` + NEW_PANE_KEY = `${NEW_TAB}:${NEW_LEAF}` + }) + + afterEach(() => { + agentHookServer.clearPaneKeyAliasesForPty(SURVIVOR) + agentHookServer.clearPaneKeyAliasesForPty(OTHER) + }) + + it('moves the row filed under the exported key and routes later hooks to the new pane', async () => { + hookFromOldShell('working') + const runtime = await relaunch([listedSurvivor(OLD_PANE_KEY)]) + + showSurvivorInNewTab(runtime) + + expect(statesOn(NEW_PANE_KEY)).toEqual(['working']) + hookFromOldShell('done') + expect(statesOn(NEW_PANE_KEY)).toEqual(['done']) + expect(statesOn(OLD_PANE_KEY)).toEqual([]) + }) + + it('keeps status under the exported key when an older host does not report it', async () => { + const runtime = await relaunch([listedSurvivor()]) + + showSurvivorInNewTab(runtime) + hookFromOldShell('working') + + expect(statesOn(OLD_PANE_KEY)).toEqual(['working']) + expect(statesOn(NEW_PANE_KEY)).toEqual([]) + }) + + it('leaves the key alone while another live terminal shows that pane', async () => { + const runtime = await relaunch([ + listedSurvivor(OLD_PANE_KEY), + { id: OTHER, cwd: TEST_WORKTREE_PATH, title: 'shell', worktreeId: TEST_WORKTREE_ID } + ]) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: OLD_TAB, + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal', + activeLeafId: OLD_LEAF, + layout: null + } + ], + leaves: [ + { + tabId: OLD_TAB, + worktreeId: TEST_WORKTREE_ID, + leafId: OLD_LEAF, + paneRuntimeId: 1, + ptyId: OTHER + } + ] + }) + + showSurvivorInNewTab(runtime) + hookFromOldShell('working') + + expect(statesOn(OLD_PANE_KEY)).toEqual(['working']) + expect(statesOn(NEW_PANE_KEY)).toEqual([]) + }) + + it('returns a moved row to the exported key once a second live terminal also exported it', async () => { + hookFromOldShell('working') + const listed = [listedSurvivor(OLD_PANE_KEY)] + const runtime = await relaunch(listed) + showSurvivorInNewTab(runtime) + listed.push({ ...listedSurvivor(OLD_PANE_KEY), id: OTHER }) + + await runtime.listTerminals() + + expect(statesOn(OLD_PANE_KEY)).toEqual(['working']) + expect(statesOn(NEW_PANE_KEY)).toEqual([]) + hookFromOldShell('done') + expect(statesOn(OLD_PANE_KEY)).toEqual(['done']) + }) + + it('leaves the row under the exported key when another live terminal exported the destination', async () => { + hookFromOldShell('working') + const runtime = await relaunch([ + listedSurvivor(OLD_PANE_KEY), + { ...listedSurvivor(NEW_PANE_KEY), id: OTHER } + ]) + + showSurvivorInNewTab(runtime) + + expect(statesOn(OLD_PANE_KEY)).toEqual(['working']) + expect(statesOn(NEW_PANE_KEY)).toEqual([]) + }) + + it('brings the row back when the terminal is shown in its exported pane again', async () => { + hookFromOldShell('working') + const runtime = await relaunch([listedSurvivor(OLD_PANE_KEY)]) + showSurvivorInNewTab(runtime) + + showSurvivorIn(runtime, OLD_TAB, OLD_LEAF) + + expect(statesOn(OLD_PANE_KEY)).toEqual(['working']) + expect(statesOn(NEW_PANE_KEY)).toEqual([]) + hookFromOldShell('done') + expect(statesOn(OLD_PANE_KEY)).toEqual(['done']) + }) +}) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 4590b74597c..cca1f51e750 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -67,6 +67,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { paneKey?: string | null /** Window-owned L80 graphSequence when paneKey was recorded; newer surfaces have no graph statement that could contradict them (pty-recorded-surface-topology.ts). */ surfaceRecordedAtGraphSequence?: number + /** The ORCA_PANE_KEY the execution host reports this PTY's env exported; absent when unreported. */ + spawnPaneKey?: string | null launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null launchIncarnationId: PtyIncarnationId | null diff --git a/src/main/runtime/session-graph/session-graph-promotion.ts b/src/main/runtime/session-graph/session-graph-promotion.ts index b03adcf5216..5f3544d3035 100644 --- a/src/main/runtime/session-graph/session-graph-promotion.ts +++ b/src/main/runtime/session-graph/session-graph-promotion.ts @@ -130,6 +130,8 @@ export abstract class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifyS ...args: Parameters ): ReturnType + protected abstract followExportedPaneKeys(): void + protected abstract scheduleMobileSessionTabsChanged( ...args: Parameters ): ReturnType diff --git a/src/main/runtime/session-graph/session-window-graph.ts b/src/main/runtime/session-graph/session-window-graph.ts index 794577d4c82..6679260704f 100644 --- a/src/main/runtime/session-graph/session-window-graph.ts +++ b/src/main/runtime/session-graph/session-window-graph.ts @@ -256,6 +256,7 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta this.leaves = nextLeaves this.rebuildLeafPtyIndex() + this.followExportedPaneKeys() this.reconcilePtyIncarnationHandles() // Graph-only changes affect mobile handles/titles/liveness too; fan out the union on the same 50ms trailing edge, reading latest state at emission. for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees( diff --git a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts index 2961af0aa95..e8726338da4 100644 --- a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts @@ -79,8 +79,10 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O getDisplayTitle: (pty) => getLatestPtyTitle(this.getPtyDisplayRecord(pty)), replayPersistedSurface: (pty, tabId, paneKey) => recordPtySurface(pty, tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING), - recordAdoptedSurface: (pty, tabId, paneKey) => - recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)), + recordAdoptedSurface: (pty, tabId, paneKey) => { + recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)) + this.followExportedPaneKeys() + }, getMobileSnapshots: () => this.mobileSessionTabsByWorktree.values(), getSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), setSession: (worktreeId, next) => this.setWorkspaceSessionForWorktree(worktreeId, next), diff --git a/src/main/runtime/terminal/identity/exported-pane-key-follow.ts b/src/main/runtime/terminal/identity/exported-pane-key-follow.ts new file mode 100644 index 00000000000..8002a879125 --- /dev/null +++ b/src/main/runtime/terminal/identity/exported-pane-key-follow.ts @@ -0,0 +1,43 @@ +import { agentHookServer } from '../../../agent-hooks/server' +import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' + +type ExportedPaneKeyPty = Pick + +/** + * A live terminal shown in a pane other than the one its env exported (a restart re-homed it into + * a new tab) keeps posting hooks under the exported key, so that key's agent status follows it. + * Run over every record at once: when another live terminal also exported or shows either key, or + * the terminal is back in its exported pane, the status returns under the exported key. + */ +export function followExportedPaneKeys( + records: Iterable, + paneKeyOf: (pty: T) => string | null +): void { + const live = Array.from(records).flatMap((pty) => + pty.connected + ? [{ ptyId: pty.ptyId, spawnPaneKey: pty.spawnPaneKey, paneKey: paneKeyOf(pty) }] + : [] + ) + const claimants = new Map() + for (const pty of live) { + for (const paneKey of new Set([pty.spawnPaneKey, pty.paneKey])) { + if (paneKey) { + claimants.set(paneKey, (claimants.get(paneKey) ?? 0) + 1) + } + } + } + for (const { ptyId, spawnPaneKey, paneKey } of live) { + if (!spawnPaneKey || !paneKey) { + continue + } + const follows = + paneKey !== spawnPaneKey && claimants.get(spawnPaneKey) === 1 && claimants.get(paneKey) === 1 + if (!follows) { + agentHookServer.returnPaneAuthority(spawnPaneKey, ptyId) + } else if (agentHookServer.resolvePaneAuthorityOwner(spawnPaneKey) !== paneKey) { + agentHookServer.transferPaneAuthority(spawnPaneKey, paneKey, ptyId, Date.now(), { + authorityVerified: true + }) + } + } +} diff --git a/src/main/runtime/terminal/identity/pty-worktree-record.ts b/src/main/runtime/terminal/identity/pty-worktree-record.ts index 7bf11730460..45f87f7f91d 100644 --- a/src/main/runtime/terminal/identity/pty-worktree-record.ts +++ b/src/main/runtime/terminal/identity/pty-worktree-record.ts @@ -20,6 +20,7 @@ import { recordPtySurfaceClaim, SURFACE_CLAIM_WITHOUT_STANDING } from '../../pty-recorded-surface-topology' +import { followExportedPaneKeys } from './exported-pane-key-follow' export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceRepoScanRefresh { /** Switch-on, a pane main owns is no proof its terminal runs: its record decides (L80). */ @@ -59,6 +60,7 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR | 'preview' | 'tabId' | 'paneKey' + | 'spawnPaneKey' | 'surfaceRecordedAtGraphSequence' | 'title' | 'connectionId' @@ -107,6 +109,7 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR state.surfaceRecordedAtGraphSequence ?? this.graphSequence ) }), + ...(state.spawnPaneKey ? { spawnPaneKey: state.spawnPaneKey } : {}), launchConfig: null, launchToken: null, launchIncarnationId: null, @@ -220,6 +223,9 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR state.surfaceRecordedAtGraphSequence ?? SURFACE_CLAIM_WITHOUT_STANDING ) } + if (state.spawnPaneKey !== undefined) { + pty.spawnPaneKey = state.spawnPaneKey + } if (state.connected !== undefined) { pty.connected = state.connected pty.disconnectedAt = state.connected ? null : (pty.disconnectedAt ?? Date.now()) @@ -250,6 +256,11 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR return pty } + /** Moves agent status keyed by a re-homed terminal's exported pane key to the pane it is in. */ + protected followExportedPaneKeys(): void { + followExportedPaneKeys(this.ptysById.values(), (pty) => this.paneOfPty(pty).paneKey) + } + protected makeRuntimePaneKey( leaf: Pick ): string { diff --git a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts index 57dae1b1712..31bcf9a2f2a 100644 --- a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts +++ b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts @@ -172,6 +172,7 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve connected: true, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), agentSessionOwners: session.incarnationId ? (session.agentSessionOwners ?? []) : [], + spawnPaneKey: session.paneKey ?? null, ...(session.wslDistro !== undefined ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } : {}), @@ -260,6 +261,7 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve connectionId }) this.pruneDisconnectedPtyRecords() + this.followExportedPaneKeys() return { livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, allLivePtyIds, diff --git a/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts index c6d47801460..163497c6059 100644 --- a/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts +++ b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts @@ -29,6 +29,7 @@ import { } from '../../terminal/recovery/foreign-pane-bindings' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { getExplicitWorktreeIdSelector } from '../../runtime-worktree-selection' +import { followExportedPaneKeys } from '../../terminal/identity/exported-pane-key-follow' export const FIRST_SURFACE_PLACEMENT_WAIT_MS = 500 @@ -162,6 +163,7 @@ export abstract class OrcaRuntimeWithRestoredTerminalPlacement extends OrcaRunti if (pty && pane) { // This claim has no graph standing; a later window graph wins. recordPtySurface(pty, pane.tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING) + followExportedPaneKeys(this.ptysById.values(), (record) => this.paneOfPty(record).paneKey) } } })