From 24d5e2bfefebaaec00be7ac66c2c6a8d4947d06a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:38:02 -0700 Subject: [PATCH] fix(agent-status): lift the closed-tab blackhole when a retracted tab comes back (STA-5679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getAgentStatusDisposition` suppresses every hook event whose tab id sits in `closedAgentStatusTabIds`, and that check runs FIRST — before the pane-key gate and before any new-turn revive. Unlike `closedAgentStatusPaneKeys`, which a `UserPromptSubmit`/`SessionStart` lifts, the tab set was add-only: nothing but a 1024-entry LRU eviction or a full server reset ever cleared it. A tab retracted by a transient or subset snapshot frame — a host restart, a cross-host collision replay — is therefore blackholed for the rest of the session even though it is still open and its agent is still working. The POSTs keep returning 204, so nothing anywhere reports a problem. The renderer already knew: `buildRemirroredClosedTabMarkerLiftPatch` lifts its own marker when a mirrored id is republished, commented "a lingering closed-tab marker would blackhole its byte-derived status for the whole session". But main's set is checked upstream of the renderer, so lifting only the renderer half could never reach it. Give main the other half of that lift: `liftClosedAgentStatusTabs()` behind an `agentStatus:liftClosedTabs` IPC, called with exactly the ids the renderer just proved alive. This does not weaken the `re-attach does not lift a closed-tab tombstone` rule — a re-attach is an inference, whereas a republished mirrored id is the host reporting the tab exists. Reproduced before the fix on a live pane: UserPromptSubmit, SessionStart and PreToolUse all returned 204 with no row, while the tab stayed open. --- .../server-closed-tab-suppression.test.ts | 73 +++++++++++++++++++ src/main/agent-hooks/server.ts | 13 ++++ src/main/ipc/agent-status-row-teardown-ipc.ts | 20 +++++ src/preload/api/agent-status-api.ts | 1 + src/preload/index.ts | 3 + .../src/runtime/web-session-tabs-sync.ts | 14 +++- .../web/preload-api/web-agent-status-api.ts | 1 + 7 files changed, 122 insertions(+), 3 deletions(-) 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: () => {}