fix(agent-status): lift the closed-tab blackhole when a retracted tab comes back (STA-5679)

`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.
This commit is contained in:
Brennan Benson
2026-08-28 00:59:18 -07:00
parent 59515beb70
commit 24d5e2bfef
7 changed files with 122 additions and 3 deletions
@@ -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()
}
})
})
+13
View File
@@ -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<string>()
@@ -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
+1
View File
@@ -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. */
+3
View File
@@ -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)
},
@@ -1302,15 +1302,17 @@ function updateBatchAgentPaneKey(
function buildRemirroredClosedTabMarkerLiftPatch(
recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'],
mirroredTerminalIds: ReadonlySet<string>
): Partial<WebSessionTabsSyncState> | null {
): { patch: Partial<WebSessionTabsSyncState>; 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<WebSessionTabsSyncState> = {
...agentStatusPatch,
@@ -16,6 +16,7 @@ export function createWebAgentStatusApi(): Partial<PreloadApi> {
drop: () => {},
reconcileEndedProcess: () => {},
dropByTabPrefix: () => {},
liftClosedTabs: () => {},
retirePaneAuthority: () => {},
restorePaneAuthority: () => {},
transferPaneAuthority: () => {}