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 fb0694c241a..e893c8a246f 100644 --- a/src/main/agent-hooks/server-closed-tab-suppression.test.ts +++ b/src/main/agent-hooks/server-closed-tab-suppression.test.ts @@ -743,3 +743,76 @@ describe('AgentHookServer closed-tab suppression bound', () => { expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) }) }) + +describe('closed-tab lift for a republished tab (STA-5679)', () => { + it('suppresses before the lift and accepts after it', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.dropStatusEntriesByTabPrefix('tab-1') + + // Control arm: proves the tab really is blackholed, so the accept below is a behavioral + // difference rather than a vacuous pass. + listener.mockClear() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'while suppressed', agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + + // The renderer saw this mirrored id republished, so the tab is provably alive again. + server.liftClosedAgentStatusTabs(['tab-1']) + + listener.mockClear() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'after lift', agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('lifts only the named tabs', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.dropStatusEntriesByTabPrefix('tab-1') + server.dropStatusEntriesByTabPrefix('tab-2') + + server.liftClosedAgentStatusTabs(['tab-1']) + + listener.mockClear() + server.ingestRemote( + { + paneKey: makePaneKey('tab-2', LEAF_2), + tabId: 'tab-2', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'still closed', agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 311ea277879..aeeb965ca85 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -2892,6 +2892,19 @@ export class AgentHookServer { return existing } + /** + * Lift the closed-tab suppression for tab ids the renderer has proven alive again (a mirrored + * id republished in a snapshot). The pane-key set below has a new-turn revive path; this set had + * none, so a tab retracted by a transient/subset frame stayed suppressed in `getAgentStatusDisposition` + * for the whole session — upstream of the renderer, so the renderer's own marker lift could not + * reach it, and every later hook 204'd into nothing (STA-5679). + */ + liftClosedAgentStatusTabs(tabIds: readonly string[]): void { + for (const tabId of tabIds) { + this.closedAgentStatusTabIds.delete(tabId) + } + } + dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) const paneKeysToClear = new Set() diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index e33e1c93789..bd2ba25aae3 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -16,6 +16,7 @@ import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary' * the pane's next event straight back to `working`. */ export function registerAgentStatusRowTeardownIpcHandlers(): void { + ipcMain.removeAllListeners('agentStatus:liftClosedTabs') ipcMain.removeAllListeners('agentStatus:drop') ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess') ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') @@ -55,6 +56,25 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { } }) + // Why: the drop above marks the tab closed with no expiry, and the main-side gate checks that + // set before anything else — so a tab retracted by a transient snapshot frame and then + // republished stayed blackholed for the session. The renderer already lifts its own marker on + // re-mirror; this is the other half of that lift (STA-5679). + ipcMain.on('agentStatus:liftClosedTabs', (_event, tabIds: unknown) => { + if (!Array.isArray(tabIds)) { + return + } + const valid = tabIds.filter((tabId): tabId is string => isValidAgentStatusDropTabId(tabId)) + if (valid.length === 0) { + return + } + try { + agentHookServer.liftClosedAgentStatusTabs(valid) + } catch (err) { + console.warn('[agent-hooks] liftClosedAgentStatusTabs failed:', err) + } + }) + ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => { if (!isValidAgentStatusDropTabId(tabId)) { return diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index fd6da88e351..14e2fdc52d1 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -35,6 +35,7 @@ export type AgentStatusApi = { reconcileEndedProcess: (paneKey: string) => void /** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */ dropByTabPrefix: (tabId: string) => void + liftClosedTabs: (tabIds: 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. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 1b75b5b56da..b436042dec2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5158,6 +5158,9 @@ const api = { dropByTabPrefix: (tabId: string): void => { ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) }, + liftClosedTabs: (tabIds: string[]): void => { + ipcRenderer.send('agentStatus:liftClosedTabs', tabIds) + }, retirePaneAuthority: (paneKey: string): void => { ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) }, diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 073263be99a..c827686f412 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1302,15 +1302,17 @@ function updateBatchAgentPaneKey( function buildRemirroredClosedTabMarkerLiftPatch( recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'], mirroredTerminalIds: ReadonlySet -): Partial | null { +): { patch: Partial; liftedTabIds: string[] } | null { let next: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'] | null = null + const liftedTabIds: string[] = [] for (const tabId of mirroredTerminalIds) { if (tabId in (recentlyClosedAgentStatusTabIds ?? {})) { next ??= { ...recentlyClosedAgentStatusTabIds } delete next[tabId] + liftedTabIds.push(tabId) } } - return next ? { recentlyClosedAgentStatusTabIds: next } : null + return next ? { patch: { recentlyClosedAgentStatusTabIds: next }, liftedTabIds } : null } /** @@ -3794,11 +3796,17 @@ function applyWebSessionTabsSnapshotWithContext( // a lingering closed-tab marker would blackhole its byte-derived status for the whole // session (host-restart subset frames, cross-host collision replays). The close-intent // filter already holds genuinely closing tabs out of the snapshot. - const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch( + const remirroredClosedTabLift = buildRemirroredClosedTabMarkerLiftPatch( retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ?? state.recentlyClosedAgentStatusTabIds, mirroredTerminalIds ) + const remirroredClosedTabLiftPatch = remirroredClosedTabLift?.patch ?? null + // Why: main's closed-tab set gates every hook event and had no expiry or revive path, so lifting + // only the renderer marker left the tab blackholed upstream of the renderer entirely (STA-5679). + if (remirroredClosedTabLift && typeof window !== 'undefined') { + window.api?.agentStatus?.liftClosedTabs?.(remirroredClosedTabLift.liftedTabIds) + } const patch: Partial = { ...agentStatusPatch, diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts index ed4636ab5fb..773b16da910 100644 --- a/src/renderer/src/web/preload-api/web-agent-status-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -16,6 +16,7 @@ export function createWebAgentStatusApi(): Partial { drop: () => {}, reconcileEndedProcess: () => {}, dropByTabPrefix: () => {}, + liftClosedTabs: () => {}, retirePaneAuthority: () => {}, restorePaneAuthority: () => {}, transferPaneAuthority: () => {}