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>
This commit is contained in:
Neil
2026-10-25 11:12:08 -07:00
committed by GitHub
co-authored by Kelvin Amoaba
parent b1751c0175
commit 76029bf74e
10 changed files with 272 additions and 2 deletions
@@ -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,
@@ -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<typeof OrcaRuntimeService>,
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<typeof OrcaRuntimeService>): 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'])
})
})
@@ -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
@@ -130,6 +130,8 @@ export abstract class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifyS
...args: Parameters<RecordPtyWorktreeMethods['recordPtyWorktree']>
): ReturnType<RecordPtyWorktreeMethods['recordPtyWorktree']>
protected abstract followExportedPaneKeys(): void
protected abstract scheduleMobileSessionTabsChanged(
...args: Parameters<ScheduleMobileSessionTabsChangedMethods['scheduleMobileSessionTabsChanged']>
): ReturnType<ScheduleMobileSessionTabsChangedMethods['scheduleMobileSessionTabsChanged']>
@@ -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(
@@ -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),
@@ -0,0 +1,43 @@
import { agentHookServer } from '../../../agent-hooks/server'
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
type ExportedPaneKeyPty = Pick<RuntimePtyWorktreeRecord, 'ptyId' | 'connected' | 'spawnPaneKey'>
/**
* 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<T extends ExportedPaneKeyPty>(
records: Iterable<T>,
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<string, number>()
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
})
}
}
}
@@ -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<RuntimeSyncedLeaf, 'tabId' | 'leafId' | 'paneRuntimeId'>
): string {
@@ -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,
@@ -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)
}
}
})