From bd9ad06a003632b153bfde66a20109c3830cfcd5 Mon Sep 17 00:00:00 2001 From: euclodius <244837876+euclodius@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:11:28 +0300 Subject: [PATCH] fix(pi): revive retired panes on live new turns --- .../server-closed-tab-suppression.test.ts | 91 +++++++++++- src/main/agent-hooks/server.ts | 132 ++++++++++++++---- src/main/index.ts | 2 + ...ay-session-agent-hooks.integration.test.ts | 35 +++++ src/main/ssh/ssh-relay-session.ts | 2 +- ...events-agent-status-store-test-fixtures.ts | 1 + ...cEvents-agent-status-pane-teardown.test.ts | 77 +++++++++- src/renderer/src/hooks/useIpcEvents.ts | 21 ++- .../store/slices/agent-pane-authority.test.ts | 30 ++++ src/renderer/src/store/slices/agent-status.ts | 16 ++- src/shared/agent-hook-listener.ts | 2 + src/shared/agent-status-types.ts | 3 + 12 files changed, 367 insertions(+), 45 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 9fe4ac51f9e1..4d46f10c9850 100644 --- a/src/main/agent-hooks/server-closed-tab-suppression.test.ts +++ b/src/main/agent-hooks/server-closed-tab-suppression.test.ts @@ -153,7 +153,9 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'late remote', agentType: 'codex' } + source: 'omp', + hookEventName: 'before_agent_start', + payload: { state: 'working', prompt: 'late OMP restart', agentType: 'omp' } }, 'conn-1' ) @@ -557,7 +559,7 @@ describe('AgentHookServer listener replay', () => { // 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 () => { + it('keeps a legacy alias fenced after its closed tab id is evicted', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) try { @@ -580,9 +582,12 @@ describe('AgentHookServer listener replay', () => { server.transferPaneAuthority(PANE, detachedPane, 'pty-cross') server.retirePaneAuthority(detachedPane) server.dropStatusEntriesByTabPrefix('tab-1') + for (let i = 0; i <= CLOSED_AGENT_STATUS_TAB_IDS_MAX; i += 1) { + server.dropStatusEntriesByTabPrefix(`tab-alias-evict-${i}`) + } - // 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. + // The owner tab is open and tab-1 has left the tab-id LRU, but the exact closed + // legacy key must still prevent its alias from being rebuilt into the live owner. expect(server.restorePaneAuthority(detachedPane)).toBe(true) await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab-1 close' }) @@ -656,11 +661,49 @@ describe('AgentHookServer listener replay', () => { } }) - it('accepts a live remote prompt but not replay after launch authority retires', () => { + it('reclaims a retired local OMP pane only for a live before_agent_start', async () => { + const server = new AgentHookServer() + const received: { authorityRestart?: true }[] = [] + server.setListener((event) => received.push(event)) + 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/omp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-omp-launch-token' })) + }) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'first OMP turn' }) + server.retirePaneAuthority(PANE) + + await postHook({ hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()).toEqual([]) + + await postHook({ hook_event_name: 'before_agent_start', prompt: 'restarted OMP turn' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'restarted OMP turn' + }) + ]) + expect(received.at(-1)).toEqual(expect.objectContaining({ authorityRestart: true })) + } finally { + server.stop() + } + }) + + it('accepts a source-less live remote prompt but not replay or source mismatch', () => { const server = new AgentHookServer() server.ingestRemote( { paneKey: PANE, + source: 'claude', hookEventName: 'UserPromptSubmit', launchToken: 'retired-remote-launch-token', payload: { state: 'working', prompt: 'first launch', agentType: 'claude' } @@ -672,6 +715,7 @@ describe('AgentHookServer listener replay', () => { server.ingestRemote( { paneKey: PANE, + source: 'claude', hookEventName: 'UserPromptSubmit', launchToken: 'retired-remote-launch-token', isReplay: true, @@ -681,12 +725,45 @@ describe('AgentHookServer listener replay', () => { ) expect(server.getStatusSnapshot()).toEqual([]) + server.ingestRemote( + { + paneKey: PANE, + source: 'omp', + hookEventName: 'before_agent_start', + payload: { state: 'invalid' } as never + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()).toEqual([]) + + server.ingestRemote( + { + paneKey: PANE, + source: 'omp', + hookEventName: 'before_agent_start', + payload: { state: 'working', prompt: 'mismatched source', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()).toEqual([]) + + server.ingestRemote( + { + paneKey: PANE, + source: 'not-a-source', + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'malformed source', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()).toEqual([]) + server.ingestRemote( { paneKey: PANE, hookEventName: 'UserPromptSubmit', launchToken: 'retired-remote-launch-token', - payload: { state: 'working', prompt: 'remote manual restart', agentType: 'claude' } + payload: { state: 'working', prompt: 'source-less remote restart', agentType: 'claude' } }, 'conn-1' ) @@ -695,7 +772,7 @@ describe('AgentHookServer listener replay', () => { expect.objectContaining({ paneKey: PANE, state: 'working', - prompt: 'remote manual restart', + prompt: 'source-less remote restart', connectionId: 'conn-1' }) ]) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 58ad669784be..9c4e68f0b5ab 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -122,6 +122,7 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, + | 'authorityRestart' | 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' @@ -741,6 +742,9 @@ export class AgentHookServer { private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() + // Why: a tab-close claim must outlive the tab-id LRU when an exact pane tombstone remains. + // Otherwise a late new-turn fallback could revive a pane from a closed tab after enough unrelated closes. + private closedTabAgentStatusPaneKeys = new Set() private connectionTimestampWatermarkById = new Map() // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. private lastWrittenJson: string | null = null @@ -769,8 +773,10 @@ export class AgentHookServer { // Why: replay is best-effort per pane so one throwing listener can't starve the rest. for (const payload of this.state.lastStatusByPaneKey.values()) { try { - // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. - listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) + // Why: replay must never regain the authority carried by a single live restart event. + const { authorityRestart: _authorityRestart, ...cachedPayload } = + payload as EnrichedAgentHookEventPayload + listener({ ...cachedPayload, isReplay: true }) } catch (err) { console.error('[agent-hooks] replay listener threw', err) } @@ -1123,28 +1129,30 @@ export class AgentHookServer { private getAgentStatusDisposition( paneKey: string, - event?: { hookEventName?: string; isReplay?: boolean } + event?: { source?: AgentHookSource; hookEventName?: string; isReplay?: boolean } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const paneRetired = this.closedAgentStatusPaneKeys.has(paneKey) || this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (tabId && this.closedAgentStatusTabIds.has(tabId)) { + if ( + this.isClosedTabAgentStatusPaneKey(paneKey) || + this.isClosedTabAgentStatusPaneKey(ownerPaneKey) + ) { return 'suppress' } if (!paneRetired) { return 'accept' } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live SessionStart proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). - if ( - (event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') && - event.isReplay !== true - ) { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + // Why: classify without mutating — remote relay payload validation can still reject this envelope. + // Source predates some relay peers, so source-less Claude restart events retain the legacy + // candidate path and are verified against the normalized payload before authority is restored. + const isLiveNewTurn = + event?.isReplay !== true && + (event?.source !== undefined + ? isNewTurnEvent(event.source, event.hookEventName) + : event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') + if (isLiveNewTurn) { return 'restart' } return 'suppress' @@ -1158,6 +1166,15 @@ export class AgentHookServer { return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) } + // Why: exact pane provenance outlives the bounded tab-id set, so every restoration + // path must consult both instead of treating tab-id eviction as proof the tab reopened. + private isClosedTabAgentStatusPaneKey(paneKey: string): boolean { + return ( + this.closedTabAgentStatusPaneKeys.has(paneKey) || + this.isClosedAgentStatusTabForPaneKey(paneKey) + ) + } + private recordRetiredPaneFence( paneKeys: ReadonlySet, aliases: readonly RetiredPaneAlias[] @@ -1177,7 +1194,7 @@ export class AgentHookServer { } } - private markPaneClosedForAgentStatus(paneKey: string): void { + private markPaneClosedForAgentStatus(paneKey: string, closedTab = false): void { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.add(paneKey) while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { @@ -1187,6 +1204,18 @@ export class AgentHookServer { } this.closedAgentStatusPaneKeys.delete(oldest) } + if (!closedTab) { + return + } + this.closedTabAgentStatusPaneKeys.delete(paneKey) + this.closedTabAgentStatusPaneKeys.add(paneKey) + while (this.closedTabAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { + const oldest = this.closedTabAgentStatusPaneKeys.keys().next().value + if (oldest === undefined) { + break + } + this.closedTabAgentStatusPaneKeys.delete(oldest) + } } private attachStatusTiming( @@ -1890,8 +1919,8 @@ export class AgentHookServer { let aliasChanged = false for (const { physicalPaneKey, entry } of fence.aliases) { if ( - this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || - this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || + this.isClosedTabAgentStatusPaneKey(physicalPaneKey) || + this.isClosedTabAgentStatusPaneKey(entry.stablePaneKey) || // Why: the pane was rebound in the meantime; the newer alias is the truth. this.legacyPaneKeyAliases.has(physicalPaneKey) ) { @@ -1918,14 +1947,17 @@ export class AgentHookServer { // (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)) { + if ( + this.isClosedTabAgentStatusPaneKey(paneKey) || + this.isClosedTabAgentStatusPaneKey(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)) { + if (this.isClosedTabAgentStatusPaneKey(key)) { continue } if (this.closedAgentStatusPaneKeys.delete(key)) { @@ -2184,7 +2216,7 @@ export class AgentHookServer { toolAgentType?: string providerSession?: unknown providerSessionOnly?: unknown - isReplay?: boolean + isReplay?: unknown /** Payload fields the relay dropped to fit an oversized frame; validated below. */ shedFields?: unknown claudeRunningNonAgentTask?: unknown @@ -2223,6 +2255,9 @@ export class AgentHookServer { if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { return } + if (envelope.isReplay !== undefined && typeof envelope.isReplay !== 'boolean') { + return + } // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. const reportedTabId = envelope.tabId !== undefined && envelope.tabId.trim().length > 0 @@ -2240,7 +2275,12 @@ export class AgentHookServer { typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 ? envelope.hookEventName.trim() : undefined - const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + // Why: undefined is the backward-compatible legacy shape; a present invalid value is + // malformed metadata and must not inherit source-less restart authority from the payload. + if (envelope.source !== undefined && !isAgentHookSource(envelope.source)) { + return + } + const source = envelope.source const providerPromptId = source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined const compactTrigger = @@ -2249,18 +2289,13 @@ export class AgentHookServer { ? envelope.compactTrigger : undefined const statusDisposition = this.getAgentStatusDisposition(paneKey, { + source, hookEventName, isReplay: envelope.isReplay === true }) if (statusDisposition === 'suppress') { return } - if (statusDisposition === 'restart') { - // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. - // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the - // stable pane, so the rebind cannot land on a legacy key. - this.observations.rebind(paneKey) - } const worktreeId = envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 ? envelope.worktreeId.trim() @@ -2298,6 +2333,20 @@ export class AgentHookServer { envelope.shedFields, this.state.lastStatusByPaneKey.get(paneKey)?.payload ) + if (statusDisposition === 'restart') { + const restartSource = + source ?? + (isAgentHookSource(normalizedPayload.agentType) ? normalizedPayload.agentType : undefined) + // Why: an optional wire source may be recovered from the canonical payload, but a + // declared source must agree with it before this envelope can regain pane authority. + if ( + restartSource === undefined || + (source !== undefined && normalizedPayload.agentType !== source) || + !isNewTurnEvent(restartSource, hookEventName) + ) { + return + } + } const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { if ( @@ -2357,6 +2406,7 @@ export class AgentHookServer { const event: AgentHookEventPayload = { paneKey, source, + authorityRestart: statusDisposition === 'restart' ? true : undefined, launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, tabId, worktreeId, @@ -2379,6 +2429,14 @@ export class AgentHookServer { : undefined, payload: normalizedPayload } + if (statusDisposition === 'restart') { + if (!this.restorePaneAuthority(paneKey)) { + return + } + // Why: a retired pane accepting a new turn is a different agent session behind the + // same key — later observations must not be ordered against the retired one. + this.observations.rebind(paneKey) + } this.recordCurrentAuthorityObservation(event) this.applyNormalizedStatus( event, @@ -2467,6 +2525,7 @@ export class AgentHookServer { const normalized = this.normalizeLocalHookPayload(source, aliasedBody) const statusDisposition = normalized.event ? this.getAgentStatusDisposition(normalized.event.paneKey, { + source: normalized.event.source, hookEventName: normalized.event.hookEventName, isReplay: normalized.event.isReplay }) @@ -2474,9 +2533,14 @@ export class AgentHookServer { if (normalized.event && statusDisposition !== 'suppress') { const event = statusDisposition === 'restart' - ? { ...normalized.event, launchToken: undefined } + ? { ...normalized.event, launchToken: undefined, authorityRestart: true as const } : normalized.event if (statusDisposition === 'restart') { + if (!this.restorePaneAuthority(event.paneKey)) { + res.writeHead(204) + res.end() + return + } // Why: a retired pane accepting a new turn is a different agent session behind the // same key — later observations must not be ordered against the retired one. this.observations.rebind(event.paneKey) @@ -2562,6 +2626,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.clear() this.closedAgentStatusTabIds.clear() this.closedAgentStatusPaneKeys.clear() + this.closedTabAgentStatusPaneKeys.clear() this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() this.legacyPaneKeyAliases.clear() @@ -2712,6 +2777,11 @@ export class AgentHookServer { paneKeysToClear.add(commitment.paneKey) } } + for (const paneKey of this.closedAgentStatusPaneKeys) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } let aliasChanged = false for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { @@ -2720,11 +2790,12 @@ export class AgentHookServer { this.legacyPaneKeyAliases.delete(legacyPaneKey) paneKeysToClear.add(legacyPaneKey) paneKeysToClear.add(entry.stablePaneKey) - this.markPaneClosedForAgentStatus(legacyPaneKey) - this.markPaneClosedForAgentStatus(entry.stablePaneKey) aliasChanged = true } } + for (const paneKey of paneKeysToClear) { + this.markPaneClosedForAgentStatus(paneKey, true) + } const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) let statusChanged = false @@ -3118,6 +3189,7 @@ export class AgentHookServer { const enrichedPayload = payload as EnrichedAgentHookEventPayload const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true const { + authorityRestart: _authorityRestart, claudeRunningNonAgentTask: _claudeRunningNonAgentTask, promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. diff --git a/src/main/index.ts b/src/main/index.ts index ecd946969658..25c33a591838 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1593,6 +1593,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow receivedAt, stateStartedAt, launchToken, + authorityRestart, providerSession, providerSessionOnly, promptInteractionKey, @@ -1638,6 +1639,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow ...payload, paneKey, ...(launchToken ? { launchToken } : {}), + ...(authorityRestart ? { authorityRestart: true } : {}), ...(terminalHandle ? { terminalHandle } : {}), tabId, worktreeId, diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 2b8fc30c8178..a3d993e13df1 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -587,6 +587,41 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { ingestSpy.mockRestore() }) + it('preserves malformed replay metadata for main trust-boundary rejection', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') + + session = createSession('conn-malformed-replay') + await session.establish({} as SshConnection) + + relay.notifyAgentHook( + makeEnvelope({ + source: 'omp', + hookEventName: 'before_agent_start', + isReplay: 'true' as never, + payload: { + state: 'working', + prompt: 'malformed replay', + agentType: 'omp' + } + }) + ) + + await vi.waitFor(() => + expect(ingestSpy).toHaveBeenCalledWith( + expect.objectContaining({ isReplay: 'true' }), + 'conn-malformed-replay' + ) + ) + expect(agentHookServer.getStatusSnapshot()).toEqual([]) + ingestSpy.mockRestore() + }) + it('tracks prompt sent from live SSH agent hooks but not replayed hooks', async () => { relay = createFakeRelay() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index eb59d0a56fad..9dcfdc2d177d 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1539,7 +1539,7 @@ export class SshRelaySession { typeof envelope.teammateName === 'string' ? envelope.teammateName : undefined, toolAgentType: typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined, - isReplay: envelope.isReplay === true ? true : undefined, + isReplay: envelope.isReplay, providerSession: envelope.providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. diff --git a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts index 609b3fb562ce..defa73cc6c50 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts @@ -34,6 +34,7 @@ export type AgentStatusSetData = { turnCompletedAt?: number terminalHandle?: string launchToken?: string + authorityRestart?: true providerSession?: { key: 'session_id'; id: string } providerSessionOnly?: boolean orchestration?: { diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts index 059d051def92..c97826666618 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts @@ -221,12 +221,87 @@ describe('useIpcEvents agent status snapshot integration', () => { prompt: 'late completion', agentType: 'codex', receivedAt: 1_700_000_000_200, - stateStartedAt: 1_699_999_999_100 + stateStartedAt: 1_699_999_999_100, + authorityRestart: true }) expect(setAgentStatus).not.toHaveBeenCalled() }) + it('applies an authoritative live restart for a retired pane', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + recentlyRetiredAgentStatusPaneKeys: { [FUTURE_PANE_KEY]: true }, + settings: { terminalFontSize: 13, notifications: { enabled: false } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => storeState } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'done', + prompt: 'late retired completion', + agentType: 'omp', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_699_999_999_100 + }) + expect(setAgentStatus).not.toHaveBeenCalled() + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'OMP new turn', + agentType: 'omp', + authorityRestart: true, + receivedAt: 1_700_000_000_200, + stateStartedAt: 1_699_999_999_100 + }) + + expect(setAgentStatus).toHaveBeenCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ state: 'working', prompt: 'OMP new turn', agentType: 'omp' }), + 'Future Tab', + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1'), + { authorityRestart: true } + ) + }) + it('keeps missing-tab runtime attribution for tabs that were never explicitly closed', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 509ccd60fe31..d69280984585 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -315,10 +315,14 @@ const recentlyRenamedWorktreeIdExpiry = new Map() function isAgentStatusForRecentlyClosedTab( store: Pick, - paneKey: string + paneKey: string, + options?: { allowRetiredPane?: boolean } ): boolean { const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) { + if ( + options?.allowRetiredPane !== true && + store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true + ) { return true } const tabId = parsePaneKey(ownerPaneKey)?.tabId @@ -3253,7 +3257,13 @@ export function useIpcEvents(): void { if (!store.workspaceSessionReady) { return 'dropped' } - if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) { + // Why: only main-authorized live new-turn hooks may revive a pane retired by + // layout changes; this deliberately does not bypass a tab-close tombstone. + if ( + isAgentStatusForRecentlyClosedTab(store, data.paneKey, { + allowRetiredPane: data.authorityRestart === true && options?.replay !== true + }) + ) { return 'dropped' } const paneKey = resolveAgentPaneAuthorityKey(data.paneKey) @@ -3471,10 +3481,11 @@ export function useIpcEvents(): void { ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) }, metadata: - data.providerSession || data.launchToken + data.providerSession || data.launchToken || data.authorityRestart === true ? { ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) + ...(data.launchToken ? { launchToken: data.launchToken } : {}), + ...(data.authorityRestart === true ? { authorityRestart: true } : {}) } : undefined } 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 a5df08cd4a87..8db71328540e 100644 --- a/src/renderer/src/store/slices/agent-pane-authority.test.ts +++ b/src/renderer/src/store/slices/agent-pane-authority.test.ts @@ -128,6 +128,36 @@ describe('agent pane authority', () => { expect(store.getState().agentStatusByPaneKey[SIBLING]).toBeUndefined() }) + it('accepts an authoritative live restart, clears its resolved tombstone, then applies done', () => { + const store = createTestStore() + store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'old turn' }) + store.getState().retireAgentPaneAuthority(TARGET) + + store + .getState() + .setAgentStatus( + TARGET, + { state: 'working', prompt: 'OMP new turn', agentType: 'omp' }, + undefined, + undefined, + undefined, + { authorityRestart: true } + ) + store.getState().setAgentStatus(TARGET, { + state: 'done', + prompt: 'OMP new turn', + agentType: 'omp' + }) + + const state = store.getState() + expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toBeUndefined() + expect(state.agentStatusByPaneKey[TARGET]).toMatchObject({ + state: 'done', + prompt: 'OMP new turn', + agentType: 'omp' + }) + }) + 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 cb86af68d381..8d795ad91713 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -132,6 +132,8 @@ export type AgentStatusMetadata = { providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string + /** A source-aware live new-turn hook may clear its pane-retirement tombstone. */ + authorityRestart?: true } export type AgentStatusUpdate = { @@ -1966,8 +1968,9 @@ export const createAgentStatusSlice: StateCreator { paneKey = resolveAgentPaneAuthorityKey(paneKey) const updatedAt = timing?.updatedAt ?? Date.now() + const authoritativelyRestarted = metadata?.authorityRestart === true if ( - paneKey in get().recentlyRetiredAgentStatusPaneKeys || + (!authoritativelyRestarted && paneKey in get().recentlyRetiredAgentStatusPaneKeys) || // Why: a closed tab is no longer a valid destination for hook replays or late status events. isRecentlyClosedAgentStatusTab( get().recentlyClosedAgentStatusTabIds, @@ -2286,6 +2289,16 @@ export const createAgentStatusSlice: StateCreator