diff --git a/src/main/agent-hooks/server-closed-tab-suppression.test.ts b/src/main/agent-hooks/server-closed-tab-suppression.test.ts index 74b3324ac5a..9fe4ac51f9e 100644 --- a/src/main/agent-hooks/server-closed-tab-suppression.test.ts +++ b/src/main/agent-hooks/server-closed-tab-suppression.test.ts @@ -267,6 +267,365 @@ describe('AgentHookServer listener replay', () => { } }) + // STA-4114: a detach/reattach cycle retires the pane, and nothing lifted the fence. + // Reviving only on a new turn cannot help a pane re-attached mid-turn (its remaining + // events are agent_end, not a new-turn event) or one re-attached idle. + for (const kind of ['pi', 'omp', 'prime-agent'] as const) { + it(`re-attaching a retired ${kind} pane restores status without needing a new turn`, async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${kind}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: `retired-${kind}-token` })) + }) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) + server.retirePaneAuthority(PANE) + + // The turn was already running, so only its completion is left to report — + // and while retired it is suppressed. This is the reported permanent failure. + await postHook({ hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()).toEqual([]) + + expect(server.restorePaneAuthority(PANE)).toBe(true) + + await postHook({ hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done' }) + ]) + } finally { + server.stop() + } + }) + } + + it('re-attaching a retired pane while idle re-opens it for a much later first turn', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'idle-reattach-token' })) + }) + + // Nothing in flight: the pane is retired and re-attached while the agent sits idle. + server.retirePaneAuthority(PANE) + expect(server.restorePaneAuthority(PANE)).toBe(true) + + // Why: prove the fence is already down before any turn event arrives. Asserting + // only on before_agent_start would also pass if a turn boundary lifted the fence, + // so it cannot distinguish re-attach revival from turn-triggered revival (#14626). + await postHook({ hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done' }) + ]) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'much later turn' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', prompt: 'much later turn' }) + ]) + } finally { + server.stop() + } + }) + + it('re-attach does not lift a closed-tab tombstone', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'closed-tab-token' })) + }) + + // Pane fence AND tab fence are both standing; re-attach may lift neither. + server.retirePaneAuthority(PANE) + server.dropStatusEntriesByTabPrefix('tab-1') + expect(server.restorePaneAuthority(PANE)).toBe(false) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab close' }) + expect(server.getStatusSnapshot()).toEqual([]) + + // The pane fence must still be standing too, not silently lifted underneath. + expect(server.restorePaneAuthority(PANE)).toBe(false) + } finally { + server.stop() + } + }) + + // STA-4114: retirement fences every alias of a pane and deletes the alias itself. + // Restoring only the owner key leaves the physical key the live process still posts + // fenced forever — the detached pane, which is the canonical re-attach case. + it('re-attaching a detached pane accepts hooks on the pane key its process launched under', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const detachedPane = makePaneKey('tab-2', LEAF_2) + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'detached-token' })) + }) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) + // Detach into another tab. The live process keeps posting PANE (server.ts:1614), + // so the alias is the only thing routing it to its new owner. + server.transferPaneAuthority(PANE, detachedPane, 'pty-detached') + server.retirePaneAuthority(detachedPane) + expect(server.restorePaneAuthority(detachedPane)).toBe(true) + + await postHook({ hook_event_name: 'agent_end' }) + // One row under the OWNER key. Lifting the fence without rebuilding the alias + // mints a second row on the stale key instead. + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: detachedPane, state: 'done' }) + ]) + } finally { + server.stop() + } + }) + + it('re-attaching restores a legacy numeric pane key alias', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const legacyPane = 'tab-1:0' + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody(payload, { paneKey: legacyPane, launchToken: 'legacy-token' }) + ) + }) + + server.registerPaneKeyAlias(legacyPane, PANE, 'pty-legacy') + await postHook({ hook_event_name: 'before_agent_start', prompt: 'legacy turn' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working' }) + ]) + + server.retirePaneAuthority(PANE) + expect(server.restorePaneAuthority(PANE)).toBe(true) + + await postHook({ hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done' }) + ]) + } finally { + server.stop() + } + }) + + it('does not rebuild a detached pane alias into a closed tab', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const detachedPane = makePaneKey('tab-2', LEAF_2) + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'detached-closed-token' })) + }) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) + server.transferPaneAuthority(PANE, detachedPane, 'pty-detached') + server.retirePaneAuthority(detachedPane) + // The tab the pane was detached into is closed: the stronger claim wins, and the + // alias must not be resurrected to route a live process into a closed tab. + server.dropStatusEntriesByTabPrefix('tab-2') + expect(server.restorePaneAuthority(detachedPane)).toBe(false) + + await postHook({ hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + server.stop() + } + }) + + // Why: the guard above short-circuits on the closed owner, so it never reaches the + // alias rebuild. Restoring the ORIGINAL key does reach it — and rebuilding the alias + // there would route a live process into the closed tab and silence it again. + it('re-opens the original pane instead of rebuilding an alias into a closed tab', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const detachedPane = makePaneKey('tab-2', LEAF_2) + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'reopen-origin-token' })) + }) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) + server.transferPaneAuthority(PANE, detachedPane, 'pty-detached') + server.retirePaneAuthority(detachedPane) + server.dropStatusEntriesByTabPrefix('tab-2') + + // The pane the process actually lives in is tab-1, which is still open. + expect(server.restorePaneAuthority(PANE)).toBe(true) + + await postHook({ hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done' }) + ]) + } finally { + server.stop() + } + }) + + // Why: closedAgentStatusTabIds is LRU-bounded, so the tab fence is not permanent. + // If restoring a sibling key lifted the closed tab's PANE fence too, eviction of the + // tab id would leave nothing at all holding that pane shut. + it('leaves a closed tab pane fenced once its tab id is evicted', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const detachedPane = makePaneKey('tab-2', LEAF_2) + const env = server.buildPtyEnv() + const postHook = ( + payload: Record, + overrides: Record = {} + ): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'evict-token', ...overrides })) + }) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) + server.transferPaneAuthority(PANE, detachedPane, 'pty-detached') + server.retirePaneAuthority(detachedPane) + server.dropStatusEntriesByTabPrefix('tab-2') + expect(server.restorePaneAuthority(PANE)).toBe(true) + + for (let i = 0; i <= CLOSED_AGENT_STATUS_TAB_IDS_MAX; i += 1) { + server.dropStatusEntriesByTabPrefix(`tab-evict-${i}`) + } + + await postHook( + { hook_event_name: 'before_agent_start', prompt: 'after eviction' }, + { paneKey: detachedPane, tabId: 'tab-2' } + ) + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + server.stop() + } + }) + + // Why: a detach re-points a legacy numeric alias at an owner in another tab, so the + // fence can hold keys from two tabs at once. A legacy key never parses as a stable + // one, so a stable-only tab check would wave it through when its own tab is closed. + it('keeps a legacy alias fenced when its own tab closed but the owner tab did not', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const legacyPane = 'tab-1:0' + const detachedPane = makePaneKey('tab-2', LEAF_2) + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody(payload, { paneKey: legacyPane, launchToken: 'legacy-cross-tab-token' }) + ) + }) + + server.registerPaneKeyAlias(legacyPane, PANE, 'pty-cross') + server.transferPaneAuthority(PANE, detachedPane, 'pty-cross') + server.retirePaneAuthority(detachedPane) + server.dropStatusEntriesByTabPrefix('tab-1') + + // The owner tab is open, so the restore proceeds — but the legacy key's own tab + // is closed, and its alias must not be rebuilt into the still-open owner. + expect(server.restorePaneAuthority(detachedPane)).toBe(true) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab-1 close' }) + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + server.stop() + } + }) + + it('does not clobber a newer alias when replaying a retired fence', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const legacyPane = 'tab-1:0' + const reboundPane = makePaneKey('tab-1', LEAF_3) + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody(payload, { paneKey: legacyPane, launchToken: 'rebind-token' }) + ) + }) + + server.registerPaneKeyAlias(legacyPane, PANE, 'pty-old') + server.retirePaneAuthority(PANE) + // The pane rebound to a different owner before the restore landed. + server.registerPaneKeyAlias(legacyPane, reboundPane, 'pty-new') + server.restorePaneAuthority(PANE) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'after rebind' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: reboundPane, state: 'working' }) + ]) + } finally { + server.stop() + } + }) + it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 2743ace64df..b301e99d6df 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -164,6 +164,12 @@ type PaneKeyAliasEntry = { updatedAt: number authorityVerified: boolean } +type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } +/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ +type RetiredPaneFence = { + paneKeys: readonly string[] + aliases: readonly RetiredPaneAlias[] +} // Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. const LAST_STATUS_FILE_NAME = 'last-status.json' @@ -187,6 +193,7 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 export const PANE_KEY_ALIASES_MAX = 1024 +export const RETIRED_PANE_FENCES_MAX = 1024 type LastStatusFile = { version: number @@ -702,6 +709,10 @@ export class AgentHookServer { private revokedHydratedAuthorityCommitments = new WeakSet() private currentAuthorityObservations = new Map() private legacyPaneKeyAliases = new Map() + // Why: indexed by every key the retirement fenced, so a re-attach on any of them + // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps + // it mirrors; an evicted record simply degrades to lifting the key it was handed. + private retiredPaneFencesByKey = new Map() private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. private lastStatusFilePath: string | null = null @@ -1118,6 +1129,33 @@ export class AgentHookServer { return 'suppress' } + // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric + // keys never parse as stable ones — resolve both forms so neither slips the tab check. + private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { + const tabId = + parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined + return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) + } + + private recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void { + const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } + for (const key of paneKeys) { + // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. + this.retiredPaneFencesByKey.delete(key) + this.retiredPaneFencesByKey.set(key, fence) + } + while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { + const oldest = this.retiredPaneFencesByKey.keys().next().value + if (oldest === undefined) { + break + } + this.retiredPaneFencesByKey.delete(oldest) + } + } + private markPaneClosedForAgentStatus(paneKey: string): void { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.add(paneKey) @@ -1755,15 +1793,18 @@ export class AgentHookServer { retirePaneAuthority(paneKey: string): void { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const paneKeys = new Set([paneKey, ownerPaneKey]) + const retiredAliases: RetiredPaneAlias[] = [] let aliasChanged = false for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { this.legacyPaneKeyAliases.delete(physicalPaneKey) + retiredAliases.push({ physicalPaneKey, entry }) paneKeys.add(physicalPaneKey) paneKeys.add(entry.stablePaneKey) aliasChanged = true } } + this.recordRetiredPaneFence(paneKeys, retiredAliases) const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) for (const key of paneKeys) { @@ -1785,6 +1826,64 @@ export class AgentHookServer { } } + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + // Lifting only the key we are handed strands the rest — a detached pane's process + // keeps posting the key it launched under, so it would stay suppressed forever with + // the fence apparently lifted. Replay the recorded fence instead: same key set, same + // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger + // claim survives and a live process is never routed back into a closed tab. + private restoreRetiredPaneFence(fence: RetiredPaneFence): void { + let aliasChanged = false + for (const { physicalPaneKey, entry } of fence.aliases) { + if ( + this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || + this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || + // Why: the pane was rebound in the meantime; the newer alias is the truth. + this.legacyPaneKeyAliases.has(physicalPaneKey) + ) { + continue + } + this.legacyPaneKeyAliases.set(physicalPaneKey, entry) + aliasChanged = true + } + for (const key of fence.paneKeys) { + if (this.retiredPaneFencesByKey.get(key) === fence) { + this.retiredPaneFencesByKey.delete(key) + } + } + if (aliasChanged) { + this.boundPaneKeyAliases() + this.notifyPaneKeyAliasPersistenceListener() + } + } + + // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that + // exact pane disproves the claim at the moment it stops being true, so the fence + // lifts here instead of waiting for the agent to speak again — an agent re-attached + // mid-turn or left idle would otherwise stay suppressed for the rest of its life + // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. + restorePaneAuthority(paneKey: string): boolean { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { + return false + } + const fence = + this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) + let restored = false + for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { + if (this.isClosedAgentStatusTabForPaneKey(key)) { + continue + } + if (this.closedAgentStatusPaneKeys.delete(key)) { + restored = true + } + } + if (fence) { + this.restoreRetiredPaneFence(fence) + } + return restored + } + clearPaneKeyAliasesForPty( ptyId: string, options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } @@ -2394,6 +2493,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.clear() this.closedAgentStatusTabIds.clear() this.closedAgentStatusPaneKeys.clear() + this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() this.legacyPaneKeyAliases.clear() clearAllListenerCaches(this.state) diff --git a/src/main/ipc/agent-pane-authority-ipc.ts b/src/main/ipc/agent-pane-authority-ipc.ts index a8956ecdbd3..da26db4e73a 100644 --- a/src/main/ipc/agent-pane-authority-ipc.ts +++ b/src/main/ipc/agent-pane-authority-ipc.ts @@ -12,7 +12,18 @@ export function registerAgentPaneAuthorityIpcHandlers( ownership: AgentPaneAuthorityOwnership ): void { ipcMain.removeAllListeners('agentStatus:retirePaneAuthority') + ipcMain.removeAllListeners('agentStatus:restorePaneAuthority') ipcMain.removeAllListeners('agentStatus:transferPaneAuthority') + ipcMain.on('agentStatus:restorePaneAuthority', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + agentHookServer.restorePaneAuthority(paneKey) + } catch (err) { + console.warn('[agent-hooks] restorePaneAuthority failed:', err) + } + }) ipcMain.on('agentStatus:retirePaneAuthority', (_event, paneKey: unknown) => { if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { return diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 6e61114df47..900b3ea4490 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -34,6 +34,8 @@ export type AgentStatusApi = { dropByTabPrefix: (tabId: string) => void /** Permanently retire one pane's hook authority while siblings stay live. */ retirePaneAuthority: (paneKey: string) => void + /** Lift one pane's retirement fence when a live PTY re-attaches to it. Closed tabs stay retired. */ + restorePaneAuthority: (paneKey: string) => void /** Move hook authority when a live pane is detached into another tab. */ transferPaneAuthority: (args: { fromPaneKey: string; toPaneKey: string; ptyId?: string }) => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 937a91153fe..3560697bfde 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5021,6 +5021,9 @@ const api = { retirePaneAuthority: (paneKey: string): void => { ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) }, + restorePaneAuthority: (paneKey: string): void => { + ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) + }, transferPaneAuthority: (args: { fromPaneKey: string toPaneKey: string diff --git a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts index eb6790e1950..d08a68fae5d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts @@ -1,6 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' import { LEAF_1, @@ -274,6 +275,11 @@ describe('connectPanePty', () => { undefined, pendingRetry.attemptId ) + // Why: binding a reattached PTY is what lifts the pane's retirement fence, so a + // pane re-attached mid-turn or idle is not suppressed forever (STA-4114). + expect(mockStoreState.restoreAgentPaneAuthority).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1) + ) }) it('rejects expired reattach state after its direct SSH retry lease is revoked', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index cfc78acc0fe..8ad0335179c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -91,6 +91,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), retireAgentPaneAuthority: vi.fn(), + restoreAgentPaneAuthority: vi.fn(), setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => { getState().paneForegroundAgentByPaneKey[paneKey] = entry }), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index 5103e378e87..5ba2747d8ff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -118,6 +118,7 @@ export type StoreState = { removeAgentStatus: ReturnType dropAgentStatus: ReturnType retireAgentPaneAuthority: ReturnType + restoreAgentPaneAuthority: ReturnType setPaneForegroundAgent: ReturnType clearPaneForegroundAgent: ReturnType markTerminalTabUnread: ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 1010bb579d8..894f6c1bdaf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3011,6 +3011,10 @@ export function connectPanePty( registerSideEffectFactConsumerForPty(ptyId) syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) + // Why: binding a live PTY here is the proof that this pane is current again, so + // lift any retirement fence left by a detach/reattach cycle before hooks arrive. + // Waiting for a new turn would strand a pane re-attached mid-turn or idle (STA-4114). + useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey) notifyCodexPaneBoundForStaleSweep(ptyId) const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] const directSshRetryAttemptId = @@ -8189,6 +8193,9 @@ export function connectPanePty( registerSideEffectFactConsumerForPty(ptyId) syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) + // Why: this is the daemon-backed reattach path — the live PTY outlived the + // renderer, so the pane is current the moment it binds (STA-4114). + useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey) notifyCodexPaneBoundForStaleSweep(ptyId) if (capturedDirectSshRetryPtyAccepted && directSshRetryAttempt) { deps.updateTabPtyId(deps.tabId, ptyId, undefined, directSshRetryAttempt.attemptId) diff --git a/src/renderer/src/store/slices/agent-pane-authority.test.ts b/src/renderer/src/store/slices/agent-pane-authority.test.ts index 31e5e242c28..a5df08cd4a8 100644 --- a/src/renderer/src/store/slices/agent-pane-authority.test.ts +++ b/src/renderer/src/store/slices/agent-pane-authority.test.ts @@ -15,6 +15,7 @@ const FINAL = makePaneKey('tab-final', '33333333-3333-4333-8333-333333333333') const SIBLING = makePaneKey('tab-target', '44444444-4444-4444-8444-444444444444') const retirePaneAuthority = vi.fn() +const restorePaneAuthority = vi.fn() const transferPaneAuthority = vi.fn() const dropByTabPrefix = vi.fn() @@ -25,6 +26,7 @@ beforeEach(() => { api: { agentStatus: { retirePaneAuthority, + restorePaneAuthority, transferPaneAuthority, dropByTabPrefix, drop: vi.fn() @@ -72,6 +74,60 @@ describe('agent pane authority', () => { expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET) }) + // STA-4114: the renderer tombstone outlived the detach/reattach cycle, so a pane + // that was still running never showed status again for the rest of its life. + it('lifts the retirement fence on re-attach so an in-flight turn can still report done', () => { + const store = createTestStore() + store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'turn in flight' }) + store.getState().retireAgentPaneAuthority(TARGET) + + // The pane re-attached mid-turn: the agent never starts a NEW turn, it only + // finishes the one already running, so a turn-triggered revival cannot fire. + store.getState().setAgentStatus(TARGET, { state: 'done', prompt: 'turn in flight' }) + expect(store.getState().agentStatusByPaneKey[TARGET]).toBeUndefined() + + store.getState().restoreAgentPaneAuthority(TARGET) + expect(store.getState().recentlyRetiredAgentStatusPaneKeys[TARGET]).toBeUndefined() + expect(restorePaneAuthority).toHaveBeenCalledWith(TARGET) + + store.getState().setAgentStatus(TARGET, { state: 'done', prompt: 'turn in flight' }) + expect(store.getState().agentStatusByPaneKey[TARGET]?.state).toBe('done') + }) + + it('re-opens a pane re-attached while idle for a turn that starts much later', () => { + const store = createTestStore() + store.getState().retireAgentPaneAuthority(TARGET) + store.getState().restoreAgentPaneAuthority(TARGET) + + store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'much later turn' }) + expect(store.getState().agentStatusByPaneKey[TARGET]?.state).toBe('working') + }) + + it('does not lift a closed-tab tombstone on re-attach', () => { + const store = createTestStore() + store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'before close' }) + store.getState().dropAgentStatusByTabPrefix('tab-target') + + store.getState().restoreAgentPaneAuthority(TARGET) + expect(restorePaneAuthority).not.toHaveBeenCalled() + + store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'after close' }) + expect(store.getState().agentStatusByPaneKey[TARGET]).toBeUndefined() + }) + + it('leaves sibling panes untouched when one pane is restored', () => { + const store = createTestStore() + store.getState().retireAgentPaneAuthority(TARGET) + store.getState().retireAgentPaneAuthority(SIBLING) + + store.getState().restoreAgentPaneAuthority(TARGET) + + expect(store.getState().recentlyRetiredAgentStatusPaneKeys[TARGET]).toBeUndefined() + expect(store.getState().recentlyRetiredAgentStatusPaneKeys[SIBLING]).toBe(true) + store.getState().setAgentStatus(SIBLING, { state: 'working', prompt: 'still fenced' }) + expect(store.getState().agentStatusByPaneKey[SIBLING]).toBeUndefined() + }) + it('can retire live pane authority while retaining a migration recovery fence', () => { const store = createTestStore() store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' }) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 013b98f01a5..e3b27077bf4 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -204,6 +204,8 @@ export type AgentStatusSlice = { paneKey: string, options?: { preserveSleepingAgentSession?: boolean } ) => void + /** Lift a pane's retirement fence once a live PTY re-attaches to it. Closed tabs stay retired. */ + restoreAgentPaneAuthority: (paneKey: string) => void transferAgentPaneAuthority: (args: { fromPaneKey: string toPaneKey: string @@ -1545,6 +1547,48 @@ export const createAgentStatusSlice: StateCreator { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + // Why: a closed tab is a stronger, separate claim — re-attach must not undo it. + if ( + isRecentlyClosedAgentStatusTab( + get().recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(ownerPaneKey) + ) + ) { + return + } + set((s) => { + const restorable = [paneKey, ownerPaneKey].filter( + (key) => key in s.recentlyRetiredAgentStatusPaneKeys + ) + if (restorable.length === 0) { + return s + } + const next = { ...s.recentlyRetiredAgentStatusPaneKeys } + for (const key of restorable) { + delete next[key] + } + return { recentlyRetiredAgentStatusPaneKeys: next } + }) + // Why: deliberately OUTSIDE the guard above, and not gated on having cleared + // anything here. This map is not a mirror of main's — main fences panes the + // renderer never hears about (retirePtyAgentLaunchAuthority on command-finished + // and PTY exit calls the hook server directly, and nothing pushes that back), and + // this map is per-window and non-persisted, so a renderer reload empties it while + // main's survives. Gating the send on a local tombstone reintroduces STA-4114 for + // exactly those panes. The send is idempotent and main refuses closed tabs itself. + if (typeof window !== 'undefined') { + window.api?.agentStatus?.restorePaneAuthority?.(ownerPaneKey) + } + }, + transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => { const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId }) if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 06622bbca93..1039afc7f5e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -942,6 +942,7 @@ function createWebPreloadApi(): Partial { drop: () => {}, dropByTabPrefix: () => {}, retirePaneAuthority: () => {}, + restorePaneAuthority: () => {}, transferPaneAuthority: () => {} }, mobile: {