From e8a7be4ce24a3c4fed44b4488966db6f7596e396 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:05:33 -0700 Subject: [PATCH] fix(omp): recover retired pane status with validated restart authority Merged after fresh run 35448889017 passed all required checks, including static analysis, typecheck, package jobs, all test shards, changed E2E, Docker SSH E2E, and verify. --- docs/reference/agent-status-store.md | 24 ++ .../server-omp-retired-ingress.test.ts | 68 +++++ .../server-omp-retirement-ack.test.ts | 89 +++++++ .../server/server-authority-aliases.ts | 3 + .../server/server-authority-fences.ts | 49 +++- .../server/server-ingest-remote.ts | 145 +++++------ .../agent-hooks/server/server-lifecycle.ts | 16 +- .../server-remote-envelope-normalization.ts | 84 ++++++ src/main/agent-hooks/server/server-state.ts | 4 +- .../server/server-status-application.ts | 70 +++++ .../server/server-status-disposition.ts | 62 ++++- .../server/server-status-update.ts | 80 +----- src/main/agent-hooks/server/server-types.ts | 5 + src/main/ipc/agent-hooks.test.ts | 2 +- src/main/ipc/agent-pane-authority-ipc.ts | 30 ++- .../ipc/agent-pane-retirement-ipc.test.ts | 43 +++ ...ay-session-agent-hooks.integration.test.ts | 25 ++ src/main/ssh/ssh-relay-session.ts | 6 +- src/main/startup/main-window-agent-status.ts | 2 + ...in-window-structured-status-filter.test.ts | 7 + src/preload/api/agent-status-api.ts | 2 +- src/preload/api/agent-status-bridge.ts | 4 +- .../components/dashboard/useRetainedAgents.ts | 2 +- .../agent-status-event-applicator.ts | 27 +- .../hooks/ipc-events/agent-status-routing.ts | 146 +---------- .../normalize-agent-status-event.ts | 15 ++ .../src/lib/agent-status-pane-ownership.ts | 134 ++++++++++ .../store/slices/agent-pane-authority.test.ts | 17 +- .../slices/agent-status-authority-actions.ts | 29 ++- .../src/store/slices/agent-status-contract.ts | 1 + .../store/slices/agent-status-drop-reducer.ts | 7 +- .../store/slices/agent-status-live-actions.ts | 48 +++- .../slices/agent-status-pane-keyed-records.ts | 71 +++-- .../slices/agent-status-slice-contract.ts | 2 +- ...rminal-pane-detach-agent-retention.test.ts | 2 +- src/shared/agent-status-ipc-payload.ts | 2 + .../agent-status-legacy-ingress-manifest.ts | 2 +- .../omp-retired-host-renderer.unit.test.ts | 245 ++++++++++++++++++ 38 files changed, 1216 insertions(+), 354 deletions(-) create mode 100644 src/main/agent-hooks/server-omp-retired-ingress.test.ts create mode 100644 src/main/agent-hooks/server-omp-retirement-ack.test.ts create mode 100644 src/main/agent-hooks/server/server-remote-envelope-normalization.ts create mode 100644 src/main/ipc/agent-pane-retirement-ipc.test.ts create mode 100644 src/renderer/src/lib/agent-status-pane-ownership.ts create mode 100644 tests/e2e/omp-retired-host-renderer.unit.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 647121155a4..eccc5848666 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -354,3 +354,27 @@ call it. the retained store restored. - Live: the parity check from #19217 (working, done, close, reload) repeated against the merged store, with both surfaces read from the one row. + +## Retired OMP pane recovery + +A desktop renderer retirement carries an optional UUID through the existing +`agentStatus:retirePaneAuthority` IPC message. The hook server retains it with +its bounded retirement fence. A validated live OMP new turn consumes that UUID +and echoes `authorityRestartId` only in the live notification. Cached rows, +persistence and startup replay never carry the acknowledgement. Older peers +omit or ignore it and retain explicit attach restoration. + +The renderer keeps the UUID in its existing non-persisted retirement tombstone; +every re-retirement mints a new one. A matching acknowledgement may clear that +tombstone only with a successful status write for the existing pane and matching +workspace/connection. Closed tombstones remain `true`, including after the tab +LRU evicts its entry. Closing a retired physical alias revokes its whole group. +This is control-plane retirement correlation, not a second agent-status store. + +Fallback restores the hook server's recorded status aliases through the existing +attach-restoration path. The accepted renderer write restores the matching status +alias routes too, preserving group membership for the next retirement. It does +not restore orchestration or launch credentials. +It is scoped to the requesting desktop renderer. A different window's retirement +UUID cannot be cleared by the acknowledgement, and web mirrors keep their existing +host-snapshot/attach behavior. diff --git a/src/main/agent-hooks/server-omp-retired-ingress.test.ts b/src/main/agent-hooks/server-omp-retired-ingress.test.ts new file mode 100644 index 00000000000..a9844a6e1c2 --- /dev/null +++ b/src/main/agent-hooks/server-omp-retired-ingress.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { PANE } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +afterEach(() => vi.restoreAllMocks()) + +const working = { agentType: 'omp', state: 'working', prompt: 'new turn' } + +describe('OMP retired-pane remote ingress', () => { + it('recovers a legacy source-less OMP new turn using validated payload identity', () => { + const server = new AgentHookServer() + server.retirePaneAuthority(PANE) + server.ingestRemote( + { paneKey: PANE, hookEventName: 'before_agent_start', payload: working }, + 'ssh' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + observation: expect.objectContaining({ boundary: true }) + }) + ]) + }) + + it.each([ + { payload: { ...working, state: 'invalid' } }, + { payload: { ...working, agentType: 'claude' } }, + { isReplay: 'true' }, + { isReplay: null }, + { launchToken: 42 }, + { providerSessionOnly: true } + ])('rejected metadata leaves retirement intact: %j', (invalid) => { + const server = new AgentHookServer() + server.retirePaneAuthority(PANE) + const input = { + paneKey: PANE, + source: 'omp', + hookEventName: 'before_agent_start', + payload: working, + ...invalid + } + // Exercise raw JSON ingress, including malformed fields a typed caller cannot create. + server.ingestRemote(JSON.parse(JSON.stringify(input)), 'ssh') + expect(server.getStatusSnapshot()).toEqual([]) + server.ingestRemote( + { + paneKey: PANE, + source: 'omp', + hookEventName: 'agent_end', + payload: { ...working, state: 'done' } + }, + 'ssh' + ) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('a replay cannot restore a retired OMP pane', () => { + const server = new AgentHookServer() + server.retirePaneAuthority(PANE) + server.ingestRemote( + { paneKey: PANE, hookEventName: 'before_agent_start', isReplay: true, payload: working }, + 'ssh' + ) + expect(server.getStatusSnapshot()).toEqual([]) + }) +}) diff --git a/src/main/agent-hooks/server-omp-retirement-ack.test.ts b/src/main/agent-hooks/server-omp-retirement-ack.test.ts new file mode 100644 index 00000000000..828e535b161 --- /dev/null +++ b/src/main/agent-hooks/server-omp-retirement-ack.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { PANE } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +const ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const hook = { + paneKey: PANE, + source: 'omp', + hookEventName: 'before_agent_start', + payload: { agentType: 'omp', state: 'working', prompt: 'new turn' } +} + +describe('OMP retirement acknowledgement', () => { + it('emits a single live acknowledgement and never trusts one in hook input', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.retirePaneAuthority(PANE, ID) + server.ingestRemote(JSON.parse(JSON.stringify({ ...hook, authorityRestartId: 'forged' })), null) + expect(listener.mock.calls[0][0].authorityRestartId).toBe(ID) + server.ingestRemote(hook, null) + expect(listener.mock.calls[1][0]).not.toHaveProperty('authorityRestartId') + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('authorityRestartId') + server.stop() + }) + + it.each(['attach', 'replacement', 'close'])('revokes recovery after %s', (operation) => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.retirePaneAuthority(PANE, ID) + if (operation === 'attach') { + server.restorePaneAuthority(PANE) + } + if (operation === 'replacement') { + server.retirePaneAuthority(PANE) + } + if (operation === 'close') { + server.dropStatusEntriesByTabPrefix('tab-1') + // Evict the tab LRU while retaining the pane fence. + for (let n = 0; n < 1025; n++) { + server.dropStatusEntriesByTabPrefix(`other-${n}`) + } + } + if (operation === 'close') { + server.retirePaneAuthority(PANE, ID) + } + server.ingestRemote(hook, null) + for (const [event] of listener.mock.calls) { + expect(event).not.toHaveProperty('authorityRestartId') + } + server.stop() + }) +}) + +it.each([false, true])('keeps repeated detached retirement coherent (closed=%s)', (closed) => { + const server = new AgentHookServer() + const listener = vi.fn() + const ownerPane = 'owner:22222222-2222-4222-8222-222222222222' + const latestId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + server.setListener(listener) + try { + server.transferPaneAuthority(PANE, ownerPane, 'pty') + server.retirePaneAuthority(ownerPane, ID) + server.retirePaneAuthority(ownerPane, latestId) + if (closed) { + server.dropStatusEntriesByTabPrefix('owner') + for (let n = 0; n < 1025; n++) { + server.dropStatusEntriesByTabPrefix(`other-${n}`) + } + } + server.ingestRemote(hook, null) + server.ingestRemote(hook, null) + if (closed) { + expect(server.getStatusSnapshot()).toEqual([]) + expect(listener).not.toHaveBeenCalled() + } else { + expect(listener.mock.calls[0][0]).toMatchObject({ + paneKey: ownerPane, + authorityRestartId: latestId + }) + expect(listener.mock.calls[1][0]).not.toHaveProperty('authorityRestartId') + } + } finally { + server.stop() + } +}) diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 9349cdb326a..83bd2cd77b7 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -144,6 +144,9 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut } const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + for (const key of [fromPaneKey, previousOwnerPaneKey, physicalPaneKey, toPaneKey]) { + this.takeRetiredPaneRestartId(key) + } const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null const previousStatus = this.state.lastStatusByPaneKey.get(previousOwnerPaneKey) as diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index fdbc16d7012..192c52495a4 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -9,10 +9,22 @@ import type { export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { // Why: retirement fences a pane and every alias of it, then deletes those aliases. - retirePaneAuthority(paneKey: string): void { + retirePaneAuthority(paneKey: string, retirementId?: string): void { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const previousFence = this.retiredPaneFencesByKey.get(ownerPaneKey) const paneKeys = new Set([paneKey, ownerPaneKey]) - const retiredAliases: RetiredPaneAlias[] = [] + for (const key of previousFence?.paneKeys ?? []) { + if ( + this.retiredPaneFencesByKey.get(key) === previousFence && + this.closedAgentStatusPaneKeys.has(key) + ) { + paneKeys.add(key) + } + } + const retiredAliases: RetiredPaneAlias[] = (previousFence?.aliases ?? []).filter( + ({ physicalPaneKey, entry }) => + paneKeys.has(physicalPaneKey) && paneKeys.has(entry.stablePaneKey) + ) let aliasChanged = false for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { @@ -23,7 +35,11 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth aliasChanged = true } } - this.recordRetiredPaneFence(paneKeys, retiredAliases) + this.recordRetiredPaneFence( + paneKeys, + retiredAliases, + this.isClosedAgentStatusTabForPaneKey(ownerPaneKey) ? undefined : retirementId + ) const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) const retiredRows = [...paneKeys].flatMap((key) => { const row = this.state.lastStatusByPaneKey.get(key) as @@ -66,6 +82,8 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth let aliasChanged = false for (const { physicalPaneKey, entry } of fence.aliases) { if ( + this.retiredPaneFencesByKey.get(physicalPaneKey) !== fence || + this.retiredPaneFencesByKey.get(entry.stablePaneKey) !== fence || this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || // Why: the pane was rebound in the meantime; the newer alias is the truth. @@ -101,7 +119,10 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth 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 ( + (fence && this.retiredPaneFencesByKey.get(key) !== fence) || + this.isClosedAgentStatusTabForPaneKey(key) + ) { continue } if (this.closedAgentStatusPaneKeys.delete(key)) { @@ -114,6 +135,26 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth return restored } + protected restoreRetiredStatusRestart(paneKey: string): { + paneKey: string + authorityRestartId?: string + } { + const authorityRestartId = this.takeRetiredPaneRestartId(paneKey) + if (!authorityRestartId) { + return { paneKey } + } + this.restorePaneAuthority(paneKey) + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + if (ownerPaneKey !== paneKey) { + const tokenHash = this.restartedStatusLaunchTokenHashByPaneKey.get(paneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + if (tokenHash) { + this.restartedStatusLaunchTokenHashByPaneKey.set(ownerPaneKey, tokenHash) + } + } + return { paneKey: ownerPaneKey, authorityRestartId } + } + clearPaneKeyAliasesForPty( ptyId: string, options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index d14714ae09b..0bc8d7947a9 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -1,11 +1,8 @@ import { track } from '../../telemetry/client' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' -import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' -import { isAgentHookSource, restoreShedStatusFields } from '../../../shared/agent-hook-relay' +import { restoreShedStatusFields } from '../../../shared/agent-hook-relay' import { MAX_PANE_KEY_LEN, - normalizeClaudePromptId, - normalizeGrokPromptId, warnOnHookEnvOrVersionMismatch } from '../../../shared/agent-hook-listener/listener-limits' import { @@ -23,6 +20,7 @@ import { olderPeerAgentStatusLegacyMode } from '../../../shared/agent-status-legacy-adapter' import { isValidPiProviderSessionOnly } from './server-status-identity' +import { normalizeRemoteEnvelopeFields } from './server-remote-envelope-normalization' import { AgentHookServerIngestStructured } from './server-ingest-structured' export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructured { @@ -82,7 +80,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS } // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. const physicalPaneKey = envelope.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) @@ -91,6 +89,12 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { return } + if ( + (envelope.isReplay !== undefined && typeof envelope.isReplay !== 'boolean') || + (envelope.launchToken !== undefined && typeof envelope.launchToken !== 'string') + ) { + return + } // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. if (envelope.isReplay === true) { const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) @@ -117,27 +121,51 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) { return } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - const hookEventName = - typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 - ? envelope.hookEventName.trim() - : undefined - const source = isAgentHookSource(envelope.source) ? envelope.source : undefined - const providerPromptId = - source === 'claude' - ? normalizeClaudePromptId(envelope.providerPromptId) - : source === 'grok' - ? normalizeGrokPromptId(envelope.providerPromptId) - : undefined - const grokPromptBoundary = - source === 'grok' && envelope.grokPromptBoundary === true ? true : undefined - const compactTrigger = - source === 'claude' && - (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') - ? envelope.compactTrigger - : undefined - const statusDisposition = this.getAgentStatusDisposition(paneKey, { + let tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const { + hookEventName, source, + providerPromptId, + grokPromptBoundary, + compactTrigger, + worktreeId, + promptInteractionKey, + toolUseId, + toolAgentId, + teammateName, + toolAgentType, + providerSession + } = normalizeRemoteEnvelopeFields(envelope) + // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). + const validatedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!validatedPayload) { + return + } + if ( + envelope.source !== undefined && + (source === 'omp' || validatedPayload.agentType === 'omp') && + envelope.source !== validatedPayload.agentType + ) { + return + } + // Why: restore a shed roster only when its digest and turn identity still match the cache. + let normalizedPayload = restoreShedStatusFields( + validatedPayload, + envelope.shedFields, + this.state.lastStatusByPaneKey.get(paneKey)?.payload + ) + if ( + envelope.providerSessionOnly === true && + !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) + ) { + return + } + // Older relays omit source; canonical OMP identity preserves boundary provenance. + const effectiveSource = + source ?? + (envelope.source === undefined && validatedPayload.agentType === 'omp' ? 'omp' : undefined) + const statusDisposition = this.getAgentStatusDisposition(paneKey, { + source: effectiveSource, rawSource: envelope.source, hookEventName, isReplay: envelope.isReplay === true, @@ -147,49 +175,20 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS if (statusDisposition === 'suppress') { return } + const restartedAuthority = + statusDisposition === 'restart' && effectiveSource === 'omp' + ? this.restoreRetiredStatusRestart(paneKey) + : undefined + if (restartedAuthority && restartedAuthority.paneKey !== paneKey) { + paneKey = restartedAuthority.paneKey + tabId = parsePaneKey(paneKey)?.tabId + } 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() - : undefined - const promptInteractionKey = - typeof envelope.promptInteractionKey === 'string' && - envelope.promptInteractionKey.trim().length > 0 - ? envelope.promptInteractionKey.trim() - : undefined - const toolUseId = - typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 - ? envelope.toolUseId.trim() - : undefined - const toolAgentId = - typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 - ? envelope.toolAgentId.trim() - : undefined - const teammateName = - typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 - ? envelope.teammateName.trim() - : undefined - const toolAgentType = - typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 - ? envelope.toolAgentType.trim() - : undefined - const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined - // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). - const validatedPayload = normalizeAgentStatusPayload(envelope.payload) - if (!validatedPayload) { - return - } - // Why: restore a shed roster only when its digest and turn identity still match the cache. - let normalizedPayload = restoreShedStatusFields( - validatedPayload, - envelope.shedFields, - this.state.lastStatusByPaneKey.get(paneKey)?.payload - ) const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) let acceptedCompactCompletion = false if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { @@ -230,17 +229,13 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS paneKey, providerPromptId ) - // Why: an older relay built this payload before the boundary flag existed, so it arrives as a - // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the - // boundary here so a compact stays silent regardless of which relay normalized it. + // Older relays omit the boundary flag; stamp it so compact completion stays silent. if (normalizedPayload.sessionBoundary !== true) { normalizedPayload = { ...normalizedPayload, sessionBoundary: true } } acceptedCompactCompletion = true } - // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the - // trigger-stripped replay is exactly the shape that arrives without one, and it is still the - // compact's own promptless event, so it still needs the summarized turn's label. + // Accepted compact completions retain the summarized turn label, including trigger-stripped replays. if ( source === 'claude' && (compactTrigger !== undefined || acceptedCompactCompletion) && @@ -249,16 +244,9 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) { normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } } - if ( - envelope.providerSessionOnly === true && - !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) - ) { - return - } const applyClaudeBackgroundWork = normalizedPayload.agentType === 'claude' && typeof envelope.claudeRunningNonAgentTask === 'boolean' && - // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. warnOnHookEnvOrVersionMismatch(this.state, { @@ -266,9 +254,12 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS env: envelope.env, expectedEnv: this.env }) - const event: AgentHookEventPayload = { + const event: AgentHookEventPayload & { authorityRestartId?: string } = { paneKey, - source, + source: effectiveSource, + ...(restartedAuthority?.authorityRestartId + ? { authorityRestartId: restartedAuthority.authorityRestartId } + : {}), launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, tabId, worktreeId, diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 9964331a086..5b8bf908d55 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -1,3 +1,4 @@ +import { parsePaneKey } from '../../../shared/stable-pane-id' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' @@ -105,9 +106,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv }) : 'suppress' if (normalized.event && statusDisposition !== 'suppress') { + const restartedAuthority = + statusDisposition === 'restart' && source === 'omp' + ? this.restoreRetiredStatusRestart(normalized.event.paneKey) + : undefined const event = statusDisposition === 'restart' - ? { ...normalized.event, launchToken: undefined } + ? { + ...normalized.event, + launchToken: undefined, + ...(restartedAuthority + ? { + ...restartedAuthority, + tabId: parsePaneKey(restartedAuthority.paneKey)?.tabId + } + : {}) + } : normalized.event if (statusDisposition === 'restart') { // Why: a retired pane accepting a new turn is a different agent session behind the diff --git a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts new file mode 100644 index 00000000000..3765c8c0a50 --- /dev/null +++ b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts @@ -0,0 +1,84 @@ +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { + normalizeClaudePromptId, + normalizeGrokPromptId +} from '../../../shared/agent-hook-listener/listener-limits' +import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' + +export type RemoteEnvelopeFields = { + hookEventName?: string + source?: AgentHookSource + providerPromptId?: string + grokPromptBoundary?: true + compactTrigger?: 'manual' | 'auto' + worktreeId?: string + promptInteractionKey?: string + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: NonNullable> +} + +export function normalizeRemoteEnvelopeFields(envelope: { + hookEventName?: string + source?: unknown + providerPromptId?: unknown + grokPromptBoundary?: unknown + compactTrigger?: unknown + worktreeId?: string + promptInteractionKey?: string + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: unknown +}): RemoteEnvelopeFields { + const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + return { + hookEventName: + typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 + ? envelope.hookEventName.trim() + : undefined, + source, + providerPromptId: + source === 'claude' + ? normalizeClaudePromptId(envelope.providerPromptId) + : source === 'grok' + ? normalizeGrokPromptId(envelope.providerPromptId) + : undefined, + grokPromptBoundary: + source === 'grok' && envelope.grokPromptBoundary === true ? true : undefined, + compactTrigger: + source === 'claude' && + (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') + ? envelope.compactTrigger + : undefined, + worktreeId: + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined, + promptInteractionKey: + typeof envelope.promptInteractionKey === 'string' && + envelope.promptInteractionKey.trim().length > 0 + ? envelope.promptInteractionKey.trim() + : undefined, + toolUseId: + typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 + ? envelope.toolUseId.trim() + : undefined, + toolAgentId: + typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 + ? envelope.toolAgentId.trim() + : undefined, + teammateName: + typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 + ? envelope.teammateName.trim() + : undefined, + toolAgentType: + typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 + ? envelope.toolAgentType.trim() + : undefined, + providerSession: normalizeAgentProviderSession(envelope.providerSession) ?? undefined + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 3302b89e1f4..b637e8082b8 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -182,9 +182,11 @@ export abstract class AgentHookServerState { } ): 'accept' | 'restart' | 'suppress' protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean + protected abstract takeRetiredPaneRestartId(paneKey: string): string | undefined protected abstract recordRetiredPaneFence( paneKeys: ReadonlySet, - aliases: readonly RetiredPaneAlias[] + aliases: readonly RetiredPaneAlias[], + retirementId?: string ): void protected abstract markPaneClosedForAgentStatus(paneKey: string): void protected abstract attachStatusTiming( diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 025c431dff6..8863dc178b9 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -9,6 +9,8 @@ import type { AgentStatusObservation, AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' +import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state' import type { EnrichedAgentHookEventPayload } from './server-types' import { agentTypeToPromptSentAgentKind } from './server-status-identity' import { AgentHookServerStatusDisposition } from './server-status-disposition' @@ -17,6 +19,74 @@ import { AgentHookServerStatusDisposition } from './server-status-disposition' const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024 export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { + protected refreshTerminalStatusEvidence( + previous: EnrichedAgentHookEventPayload, + mutationBefore?: EnrichedAgentHookEventPayload, + emitEnrichedStatus = false + ): void { + if (!this.canWriteLegacyStatusRow(previous)) { + return + } + const connectionClearWatermark = previous.connectionId + ? this.connectionTimestampWatermarkById.get(previous.connectionId) + : undefined + const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) + if (previous.connectionId) { + this.connectionTimestampWatermarkById.set(previous.connectionId, now) + } + const { + receivedAt: _receivedAt, + evidenceObservedAt: _evidenceObservedAt, + stateStartedAt, + observation: _observation, + restoredUnconfirmed: _restoredUnconfirmed, + isReplay: _isReplay, + ...payload + } = previous + const refreshed: EnrichedAgentHookEventPayload = { + ...payload, + receivedAt: now, + evidenceObservedAt: now, + stateStartedAt, + observation: this.stampObservation(payload, 'osc', now) + } + const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) + this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) + if (!this.writeLegacyStatusRow(refreshed)) { + return + } + this.commitStatusRowMutation(mutationBefore ?? previous, refreshed) + this.scheduleStatusPersist() + // A dismissed row may retain only provider resume identity. Its preserved payload can still + // read `working`, but it is deliberately hidden from live readers and must not renew awake or + // mobile freshness leases. + if (refreshed.providerSessionOnly === true) { + return + } + if (firstRuntimeObservation) { + this.notifyStatusChangeListeners() + } + this.emitStatusFreshnessObservation({ + paneKey: refreshed.paneKey, + state: refreshed.payload.state, + receivedAt: refreshed.receivedAt, + observedInCurrentRuntime: true, + ...(refreshed.worktreeId ? { worktreeId: refreshed.worktreeId } : {}), + ...(refreshed.terminalHandle ? { terminalHandle: refreshed.terminalHandle } : {}) + }) + if (emitEnrichedStatus) { + this.emitEnrichedStatus(refreshed) + } + } + + protected writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): boolean { + return admitLegacyAgentStatus( + this.state, + 'main-status-update', + entry, + AGENT_STATUS_2A_CURRENT_PRODUCER_MODE + ) + } /** `observedAt` is the producer's own clock for evidence that has one (a session journal); it * stamps the evidence and state-start times while `receivedAt` keeps delivery order. */ protected attachStatusTiming( diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index c4b7230bc2b..e8ac4ddd31e 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -16,6 +16,21 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. this.closedAgentStatusTabIds.delete(tabId) this.closedAgentStatusTabIds.add(tabId) + for (const key of this.state.lastStatusByPaneKey.keys()) { + if ( + (parsePaneKey(key)?.tabId ?? parseLegacyNumericPaneKey(key)?.tabId) === tabId && + !this.retiredPaneFencesByKey.has(key) + ) { + this.recordRetiredPaneFence(new Set([key]), []) + } + } + for (const [key, fence] of this.retiredPaneFencesByKey) { + const ownerTabId = parsePaneKey(key)?.tabId ?? parseLegacyNumericPaneKey(key)?.tabId + if (ownerTabId === tabId) { + fence.retirementIdsByPaneKey = {} + fence.closed = true + } + } while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { const oldest = this.closedAgentStatusTabIds.keys().next().value if (oldest === undefined) { @@ -43,7 +58,22 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt this.closedAgentStatusPaneKeys.has(ownerPaneKey) const tabId = parsePaneKey(ownerPaneKey)?.tabId ?? parseLegacyNumericPaneKey(ownerPaneKey)?.tabId - if (tabId && this.closedAgentStatusTabIds.has(tabId)) { + if ( + (tabId && this.closedAgentStatusTabIds.has(tabId)) || + this.retiredPaneFencesByKey.get(ownerPaneKey)?.closed + ) { + return 'suppress' + } + const retirementFence = this.retiredPaneFencesByKey.get(ownerPaneKey) + if ( + paneRetired && + event?.source === 'omp' && + retirementFence?.aliases.some( + ({ physicalPaneKey, entry }) => + this.retiredPaneFencesByKey.get(physicalPaneKey) !== retirementFence || + this.retiredPaneFencesByKey.get(entry.stablePaneKey) !== retirementFence + ) + ) { return 'suppress' } if (!paneRetired) { @@ -129,11 +159,37 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) } + protected takeRetiredPaneRestartId(paneKey: string): string | undefined { + const fence = this.retiredPaneFencesByKey.get(paneKey) + const id = fence?.retirementIdsByPaneKey[paneKey] + if (fence) { + fence.retirementIdsByPaneKey = {} + } + return id + } + protected recordRetiredPaneFence( paneKeys: ReadonlySet, - aliases: readonly RetiredPaneAlias[] + aliases: readonly RetiredPaneAlias[], + retirementId?: string ): void { - const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } + const closed = [...paneKeys].some( + (key) => + this.retiredPaneFencesByKey.get(key)?.closed || this.isClosedAgentStatusTabForPaneKey(key) + ) + const retirementIdsByPaneKey: Record = {} + for (const key of paneKeys) { + const id = closed ? undefined : retirementId + if (id) { + retirementIdsByPaneKey[key] = id + } + } + const fence: RetiredPaneFence = { + paneKeys: [...paneKeys], + aliases, + retirementIdsByPaneKey, + ...(closed ? { closed: true as const } : {}) + } for (const key of paneKeys) { // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. this.retiredPaneFencesByKey.delete(key) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 6adfd9430af..1b4a0e75960 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -10,8 +10,6 @@ import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' -import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' -import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state' import { attachClaudeChildOnlyBoundary, attachClaudePermissionToolUseId, @@ -24,12 +22,13 @@ import { AgentHookServerStatusApplication } from './server-status-application' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected applyNormalizedStatus( - payload: AgentHookEventPayload, + incoming: AgentHookEventPayload & { authorityRestartId?: string }, onAccepted?: () => void, origin: AgentStatusObservationOrigin = 'hook', observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { + const { authorityRestartId, ...payload } = incoming if (!this.canWriteLegacyStatusRow(payload)) { return undefined } @@ -241,76 +240,11 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.scheduleStatusPersist() } this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) + this.emitEnrichedStatus( + authorityRestartId && payload.isReplay !== true + ? { ...enriched, authorityRestartId } + : enriched + ) return enriched } - - protected refreshTerminalStatusEvidence( - previous: EnrichedAgentHookEventPayload, - mutationBefore?: EnrichedAgentHookEventPayload, - emitEnrichedStatus = false - ): void { - if (!this.canWriteLegacyStatusRow(previous)) { - return - } - const connectionClearWatermark = previous.connectionId - ? this.connectionTimestampWatermarkById.get(previous.connectionId) - : undefined - const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) - if (previous.connectionId) { - this.connectionTimestampWatermarkById.set(previous.connectionId, now) - } - const { - receivedAt: _receivedAt, - evidenceObservedAt: _evidenceObservedAt, - stateStartedAt, - observation: _observation, - restoredUnconfirmed: _restoredUnconfirmed, - isReplay: _isReplay, - ...payload - } = previous - const refreshed: EnrichedAgentHookEventPayload = { - ...payload, - receivedAt: now, - evidenceObservedAt: now, - stateStartedAt, - observation: this.stampObservation(payload, 'osc', now) - } - const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) - this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) - if (!this.writeLegacyStatusRow(refreshed)) { - return - } - this.commitStatusRowMutation(mutationBefore ?? previous, refreshed) - this.scheduleStatusPersist() - // A dismissed row may retain only provider resume identity. Its preserved payload can still - // read `working`, but it is deliberately hidden from live readers and must not renew awake or - // mobile freshness leases. - if (refreshed.providerSessionOnly === true) { - return - } - if (firstRuntimeObservation) { - this.notifyStatusChangeListeners() - } - this.emitStatusFreshnessObservation({ - paneKey: refreshed.paneKey, - state: refreshed.payload.state, - receivedAt: refreshed.receivedAt, - observedInCurrentRuntime: true, - ...(refreshed.worktreeId ? { worktreeId: refreshed.worktreeId } : {}), - ...(refreshed.terminalHandle ? { terminalHandle: refreshed.terminalHandle } : {}) - }) - if (emitEnrichedStatus) { - this.emitEnrichedStatus(refreshed) - } - } - - private writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): boolean { - return admitLegacyAgentStatus( - this.state, - 'main-status-update', - entry, - AGENT_STATUS_2A_CURRENT_PRODUCER_MODE - ) - } } diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index b4cf176b176..8f5edf42106 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -10,6 +10,8 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-ty // Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { + /** Live acknowledgement of the matching renderer retirement; never cached. */ + authorityRestartId?: string receivedAt: number /** When this evidence was first observed, as distinct from `receivedAt`. A relay reconnect * replays cached rows and `receivedAt` must restamp to clear the connection watermark, so @@ -29,6 +31,7 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, + | 'authorityRestartId' | 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' @@ -114,6 +117,8 @@ export type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEnt export type RetiredPaneFence = { paneKeys: readonly string[] aliases: readonly RetiredPaneAlias[] + closed?: true + retirementIdsByPaneKey: Record } export type LastStatusFile = { diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index f8e3420720a..fe488b087c5 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -444,7 +444,7 @@ describe('agent pane authority IPC', () => { onHandlers.get('agentStatus:retirePaneAuthority')!({}, PANE_KEY) - expect(retirePaneAuthority).toHaveBeenCalledWith(PANE_KEY) + expect(retirePaneAuthority).toHaveBeenCalledWith(PANE_KEY, undefined) expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) }) diff --git a/src/main/ipc/agent-pane-authority-ipc.ts b/src/main/ipc/agent-pane-authority-ipc.ts index da26db4e73a..7e8ecc93382 100644 --- a/src/main/ipc/agent-pane-authority-ipc.ts +++ b/src/main/ipc/agent-pane-authority-ipc.ts @@ -1,4 +1,5 @@ import { ipcMain } from 'electron' +import { isStablePaneId } from '../../shared/stable-pane-id' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import { clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' @@ -24,17 +25,26 @@ export function registerAgentPaneAuthorityIpcHandlers( console.warn('[agent-hooks] restorePaneAuthority failed:', err) } }) - ipcMain.on('agentStatus:retirePaneAuthority', (_event, paneKey: unknown) => { - if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { - return + ipcMain.on( + 'agentStatus:retirePaneAuthority', + (_event, paneKey: unknown, retirementId: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + if ( + retirementId !== undefined && + (typeof retirementId !== 'string' || !isStablePaneId(retirementId)) + ) { + return + } + agentHookServer.retirePaneAuthority(paneKey, retirementId) + clearMigrationUnsupportedPtysForPaneKey(paneKey) + } catch (err) { + console.warn('[agent-hooks] retirePaneAuthority failed:', err) + } } - try { - agentHookServer.retirePaneAuthority(paneKey) - clearMigrationUnsupportedPtysForPaneKey(paneKey) - } catch (err) { - console.warn('[agent-hooks] retirePaneAuthority failed:', err) - } - }) + ) ipcMain.on('agentStatus:transferPaneAuthority', (_event, value: unknown) => { if (!value || typeof value !== 'object') { return diff --git a/src/main/ipc/agent-pane-retirement-ipc.test.ts b/src/main/ipc/agent-pane-retirement-ipc.test.ts new file mode 100644 index 00000000000..1d64bb0f479 --- /dev/null +++ b/src/main/ipc/agent-pane-retirement-ipc.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' + +const mocks = vi.hoisted(() => ({ + handlers: new Map void>(), + retire: vi.fn(), + clear: vi.fn() +})) +vi.mock('electron', () => ({ + ipcMain: { + removeAllListeners: vi.fn(), + on: (name: string, callback: (...args: unknown[]) => void) => mocks.handlers.set(name, callback) + } +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { retirePaneAuthority: mocks.retire }, + isValidPaneKey: (key: string) => key.includes(':') +})) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ + clearMigrationUnsupportedPtysForPaneKey: mocks.clear +})) +import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc' +const id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const paneKey = makePaneKey('tab-1', id) +beforeEach(() => { + vi.clearAllMocks() + registerAgentPaneAuthorityIpcHandlers({ ownsPty: () => false }) +}) + +describe('pane retirement IPC request identity', () => { + it.each([undefined, id])('accepts legacy omission or a valid UUID: %s', (requestId) => { + mocks.handlers.get('agentStatus:retirePaneAuthority')?.({}, paneKey, requestId) + expect(mocks.retire).toHaveBeenCalledWith(paneKey, requestId) + }) + it.each([null, '', 'bad-id', 42, {}, `${id} `])( + 'rejects malformed request identity %j', + (requestId) => { + mocks.handlers.get('agentStatus:retirePaneAuthority')?.({}, paneKey, requestId) + expect(mocks.retire).not.toHaveBeenCalled() + expect(mocks.clear).not.toHaveBeenCalled() + } + ) +}) 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 87d5c77f205..0ac77cfd32c 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 @@ -684,6 +684,31 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) + it.each([{ isReplay: 'true' }, { isReplay: null }, { launchToken: 42 }])( + 'rejects malformed OMP authority metadata before forwarding: %j', + async (invalid) => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + session = createSession('conn-omp-invalid') + await session.establish({} as SshConnection) + const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') + const envelope = makeEnvelope({ + source: 'omp', + hookEventName: 'before_agent_start', + payload: { agentType: 'omp', state: 'working', prompt: 'new turn' } + }) + relay.notifyAgentHook(JSON.parse(JSON.stringify({ ...envelope, ...invalid }))) + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + expect(ingestSpy).not.toHaveBeenCalled() + ingestSpy.mockRestore() + } + ) + it('preserves replay metadata from remote hook notifications', 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 6108f990d26..3bc02dd734a 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1574,7 +1574,11 @@ export class SshRelaySession { return } const envelope = params - if (typeof envelope.paneKey !== 'string') { + if ( + typeof envelope.paneKey !== 'string' || + (envelope.isReplay !== undefined && typeof envelope.isReplay !== 'boolean') || + (envelope.launchToken !== undefined && typeof envelope.launchToken !== 'string') + ) { return } // Why: forward the agent CLI's env/version verbatim (not the relay's) so warn-once protocol-mismatch diagnostics fire for remote events too. diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 0f10a373bf9..9d8b9246d7d 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -44,6 +44,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt restoredUnconfirmed, observation, isReplay, + authorityRestartId, structuredHost }) => { if (state.mainWindow?.isDestroyed()) { @@ -88,6 +89,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt }) : false const statusEvent = { + ...(authorityRestartId && isReplay !== true ? { authorityRestartId } : {}), ...payload, paneKey, ...(launchToken ? { launchToken } : {}), diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts index a3cd1bfdf0a..689eb4e85e6 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -88,3 +88,10 @@ describe('the main-window agent-status listener', () => { ]) }) }) + +it('forwards retirement acknowledgement only on live status delivery', () => { + hooks.listener!(statusPayload({ authorityRestartId: 'retirement-id' })) + hooks.listener!(statusPayload({ authorityRestartId: 'retirement-id', isReplay: true })) + expect(sent[0].event).toHaveProperty('authorityRestartId', 'retirement-id') + expect(sent[1].event).not.toHaveProperty('authorityRestartId') +}) diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 7aa6c21115d..0cfce06803b 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -41,7 +41,7 @@ export type AgentStatusApi = { /** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */ dropByTabPrefix: (tabId: string) => void /** Permanently retire one pane's hook authority while siblings stay live. */ - retirePaneAuthority: (paneKey: string) => void + retirePaneAuthority: (paneKey: string, retirementId?: 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. */ diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 3cc1654aaed..5b3566a8711 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -80,8 +80,8 @@ export const agentStatusApi = { dropByTabPrefix: (tabId: string): void => { ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) }, - retirePaneAuthority: (paneKey: string): void => { - ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) + retirePaneAuthority: (paneKey: string, retirementId?: string): void => { + ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey, retirementId) }, restorePaneAuthority: (paneKey: string): void => { ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 2e39205a84c..f0ab639e2ba 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -239,7 +239,7 @@ export function collectRetainedAgentsOnDisappear(args: { retainedAgentsByPaneKey: Record retentionSuppressedPaneKeys: Record recentlyClosedAgentStatusTabIds: Record - recentlyRetiredAgentStatusPaneKeys: Record + recentlyRetiredAgentStatusPaneKeys: Record /** Live tabs by id; supplies the real destination tab when a pane key transferred. */ tabIndex?: Map }): { diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index a4872822252..7a9117e3360 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -30,7 +30,10 @@ import type { AgentStatusApplyResult, PendingAgentStatusEvent } from './agent-status-bridge-types' -import { normalizeAgentStatusEvent } from './normalize-agent-status-event' +import { + normalizeAgentStatusEvent, + normalizeAgentStatusMetadata +} from './normalize-agent-status-event' export function createAgentStatusEventApplicator(args: { pendingAgentStatusEvents: PendingAgentStatusEvent[] @@ -50,7 +53,9 @@ export function createAgentStatusEventApplicator(args: { if (!store.workspaceSessionReady) { return 'dropped' } - if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) { + const authorityRestartId = + options?.replay !== true && data.agentType === 'omp' ? data.authorityRestartId : undefined + if (isAgentStatusForRecentlyClosedTab(store, data.paneKey, authorityRestartId)) { return 'dropped' } const paneKey = resolveAgentPaneAuthorityKey(data.paneKey) @@ -59,8 +64,7 @@ export function createAgentStatusEventApplicator(args: { if (!payload) { return 'dropped' } - // Why: the memoized index answers the leading edge with the same first-match ownership the - // standalone resolver produced, without its worktree x tab rescan per event. + // Reuse first-match pane ownership without rescanning every worktree per event. const routingIndex = options?.batch?.routingIndex ?? createAgentStatusPaneRoutingIndex(store) let { exists, @@ -81,7 +85,12 @@ export function createAgentStatusEventApplicator(args: { identityTitle = projectedTitles.identityTitle } tabTitle = options?.batch?.tabTitlesByTabId.get(ownerTabId ?? '') ?? tabTitle - if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { + if ( + !exists && + !authorityRestartId && + data.worktreeId && + hasRuntimeBackedWorktreeAttribution(data) + ) { const fallbackOwnership = resolveWorktreeConnectionFromRoutingIndex( routingIndex, data.worktreeId @@ -239,13 +248,7 @@ export function createAgentStatusEventApplicator(args: { terminalHandle: data.terminalHandle, ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) }, - metadata: - data.providerSession || data.launchToken - ? { - ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) - } - : undefined + metadata: normalizeAgentStatusMetadata(data, authorityRestartId) } const applyPostCommitNotification = (): void => { if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { diff --git a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts index 7cf8d47a22d..325b43a6720 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts @@ -1,4 +1,3 @@ -import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import type { AppState } from '../../store/types' import { titleHasAgentName } from '../../../../shared/agent-detection' @@ -7,15 +6,22 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' -import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import type { useAppStore } from '../../store' export function isAgentStatusForRecentlyClosedTab( store: Pick, - paneKey: string + paneKey: string, + authorityRestartId?: string ): boolean { const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) { + if (authorityRestartId && ownerPaneKey !== paneKey) { + return true + } + const retirement = store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] + if ( + retirement !== undefined && + (typeof retirement !== 'string' || retirement !== authorityRestartId) + ) { return true } const tabId = parsePaneKey(ownerPaneKey)?.tabId @@ -86,136 +92,6 @@ export function shouldApplyResolvedAgentTerminalTitleToTab( return true } -/** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, - * and the owning repo's connectionId. Used for agent-type inference and to drop - * status updates for torn-down tabs or dead connections (an SSH reconnect retires the - * old connectionId, so events still in flight under it must not land). */ -export function resolvePaneKey( - store: ReturnType, - paneKey: string -): { - exists: boolean - title: string | undefined - identityTitle: string | undefined - repoConnectionId: string | null - repoConnectionResolved: boolean - owningWorktreeId: string | undefined - titleUsesTabTitle: boolean - /** The tab record's own title, which is the slot the hook-driven tab write actually overwrites. */ - tabTitle: string | undefined -} { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false, - tabTitle: undefined - } - } - const { tabId, leafId } = parsed - const layout = store.terminalLayoutsByTabId?.[tabId] - let exists = false - let tabTitle: string | undefined - let unifiedTabLabel: string | undefined - let owningWorktreeId: string | undefined - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { - for (const tab of tabs) { - if (tab.id === tabId) { - exists = true - tabTitle = tab.title - owningWorktreeId = worktreeId - const visibleTab = (store.unifiedTabsByWorktree?.[worktreeId] ?? []).find( - (entry) => entry.contentType === 'terminal' && entry.entityId === tabId - ) - const rawVisibleLabel = visibleTab?.label?.trim() - unifiedTabLabel = - rawVisibleLabel && rawVisibleLabel.length > 0 ? rawVisibleLabel : undefined - break - } - } - if (exists) { - break - } - } - // Why: keep "resolved to a local repo" distinct from "not hydrated yet" so callers filter strictly post-hydration but still accept SSH snapshots during the startup ownership gap. - let repoConnectionId: string | null = null - let repoConnectionResolved = false - if (owningWorktreeId !== undefined) { - const worktree = getWorktreeMapFromState(store).get(owningWorktreeId) - if (worktree) { - const repo = getRepoMapFromState(store).get(worktree.repoId) - repoConnectionResolved = repo !== undefined - repoConnectionId = repo?.connectionId ?? null - } - } - if (!exists) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: false, - tabTitle: undefined - } - } - // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. - const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true - if (!leafExists) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: false, - tabTitle: undefined - } - } - // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. - const rawPaneTitle = layout?.titlesByLeafId?.[leafId] - // Why: treat empty-string paneTitle as "no title" so the tab-level fallback fires; nullish-coalescing on '' would short-circuit and erase cached terminalTitle. - const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined - return { - exists, - title: paneTitle ?? tabTitle, - // Why: some agents (OpenClaude) keep the terminal title generic while the tab label carries the agent identity; use only the non-custom label for attribution. - identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined, - tabTitle - } -} - -export function resolveWorktreeConnection( - store: ReturnType, - worktreeId: string -): { - worktreeExists: boolean - repoConnectionId: string | null - repoConnectionResolved: boolean -} { - const worktree = getWorktreeMapFromState(store).get(worktreeId) - if (!worktree) { - return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } - } - const repo = getRepoMapFromState(store).get(worktree.repoId) - return { - worktreeExists: true, - repoConnectionId: repo?.connectionId ?? null, - repoConnectionResolved: repo !== undefined - } -} - export function resolveHookPayloadAgentType( payload: ParsedAgentStatusPayload, terminalTitle: string | undefined @@ -230,3 +106,5 @@ export function resolveHookPayloadAgentType( // Why: OpenClaude emits Claude-compatible hooks; the title is the last renderer signal to keep it out of Claude-only status paths. return { ...payload, agentType: 'openclaude' } } + +export { resolvePaneKey, resolveWorktreeConnection } from '../../lib/agent-status-pane-ownership' diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index 4fb53a07eb9..56d46657fe7 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -1,3 +1,4 @@ +import type { AgentStatusMetadata } from '../../store/slices/agent-status-contract' import { normalizeAgentStatusPayload, type AgentStatusIpcPayload, @@ -24,3 +25,17 @@ export function normalizeAgentStatusEvent( subagents: data.subagents }) } + +export function normalizeAgentStatusMetadata( + data: AgentStatusIpcPayload, + authorityRestartId?: string +): AgentStatusMetadata | undefined { + if (!data.providerSession && !data.launchToken && !authorityRestartId) { + return undefined + } + return { + ...(authorityRestartId ? { authorityRestartId } : {}), + ...(data.providerSession ? { providerSession: data.providerSession } : {}), + ...(data.launchToken ? { launchToken: data.launchToken } : {}) + } +} diff --git a/src/renderer/src/lib/agent-status-pane-ownership.ts b/src/renderer/src/lib/agent-status-pane-ownership.ts new file mode 100644 index 00000000000..430aac83af6 --- /dev/null +++ b/src/renderer/src/lib/agent-status-pane-ownership.ts @@ -0,0 +1,134 @@ +import type { AppState } from '../store/types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { collectLeafIdsInOrder } from '../components/terminal-pane/terminal-layout-leaf-ids' +import { getIndexedRepoMap, getIndexedWorktreeMap } from '../store/worktree-repo-index' + +/** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, + * and the owning repo's connectionId. Used for agent-type inference and to drop + * status updates for torn-down tabs or dead connections (an SSH reconnect retires the + * old connectionId, so events still in flight under it must not land). */ +export function resolvePaneKey( + store: AppState, + paneKey: string +): { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean + /** The tab record's own title, which is the slot the hook-driven tab write actually overwrites. */ + tabTitle: string | undefined +} { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false, + tabTitle: undefined + } + } + const { tabId, leafId } = parsed + const layout = store.terminalLayoutsByTabId?.[tabId] + let exists = false + let tabTitle: string | undefined + let unifiedTabLabel: string | undefined + let owningWorktreeId: string | undefined + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.id === tabId) { + exists = true + tabTitle = tab.title + owningWorktreeId = worktreeId + const visibleTab = (store.unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) + const rawVisibleLabel = visibleTab?.label?.trim() + unifiedTabLabel = + rawVisibleLabel && rawVisibleLabel.length > 0 ? rawVisibleLabel : undefined + break + } + } + if (exists) { + break + } + } + // Why: keep "resolved to a local repo" distinct from "not hydrated yet" so callers filter strictly post-hydration but still accept SSH snapshots during the startup ownership gap. + let repoConnectionId: string | null = null + let repoConnectionResolved = false + if (owningWorktreeId !== undefined) { + const worktree = getIndexedWorktreeMap(store.worktreesByRepo).get(owningWorktreeId) + if (worktree) { + const repo = getIndexedRepoMap(store.repos).get(worktree.repoId) + repoConnectionResolved = repo !== undefined + repoConnectionId = repo?.connectionId ?? null + } + } + if (!exists) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: false, + tabTitle: undefined + } + } + // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. + const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true + if (!leafExists) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: false, + tabTitle: undefined + } + } + // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] + // Why: treat empty-string paneTitle as "no title" so the tab-level fallback fires; nullish-coalescing on '' would short-circuit and erase cached terminalTitle. + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + return { + exists, + title: paneTitle ?? tabTitle, + // Why: some agents (OpenClaude) keep the terminal title generic while the tab label carries the agent identity; use only the non-custom label for attribution. + identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined, + tabTitle + } +} + +export function resolveWorktreeConnection( + store: AppState, + worktreeId: string +): { + worktreeExists: boolean + repoConnectionId: string | null + repoConnectionResolved: boolean +} { + const worktree = getIndexedWorktreeMap(store.worktreesByRepo).get(worktreeId) + if (!worktree) { + return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } + } + const repo = getIndexedRepoMap(store.repos).get(worktree.repoId) + return { + worktreeExists: true, + repoConnectionId: repo?.connectionId ?? null, + repoConnectionResolved: repo !== 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 767104fda87..a2a4912d023 100644 --- a/src/renderer/src/store/slices/agent-pane-authority.test.ts +++ b/src/renderer/src/store/slices/agent-pane-authority.test.ts @@ -70,11 +70,11 @@ describe('agent pane authority', () => { expect(state.agentLaunchConfigByPaneKey[TARGET]).toBeUndefined() expect(state.sleepingAgentSessionsByPaneKey[TARGET]).toBeUndefined() expect(state.agentStatusByPaneKey[SIBLING]).toBeDefined() - expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toBe(true) - expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET) + expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toEqual(expect.any(String)) + expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET, expect.any(String)) }) - it('re-retiring an already-retired pane keeps the retired-key map identity and epochs', () => { + it('re-retiring an already-retired pane renews recovery identity without changing epochs', () => { const store = createTestStore() store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' }) store.getState().retireAgentPaneAuthority(TARGET) @@ -83,7 +83,9 @@ describe('agent pane authority', () => { store.getState().retireAgentPaneAuthority(TARGET) const after = store.getState() - expect(after.recentlyRetiredAgentStatusPaneKeys).toBe(before.recentlyRetiredAgentStatusPaneKeys) + expect(after.recentlyRetiredAgentStatusPaneKeys[TARGET]).not.toBe( + before.recentlyRetiredAgentStatusPaneKeys[TARGET] + ) expect(after.agentStatusEpoch).toBe(before.agentStatusEpoch) expect(after.sortEpoch).toBe(before.sortEpoch) }) @@ -147,11 +149,12 @@ describe('agent pane authority', () => { const store = createTestStore() store.getState().retireAgentPaneAuthority(TARGET) store.getState().retireAgentPaneAuthority(SIBLING) + const siblingRetirement = store.getState().recentlyRetiredAgentStatusPaneKeys[SIBLING] store.getState().restoreAgentPaneAuthority(TARGET) expect(store.getState().recentlyRetiredAgentStatusPaneKeys[TARGET]).toBeUndefined() - expect(store.getState().recentlyRetiredAgentStatusPaneKeys[SIBLING]).toBe(true) + expect(store.getState().recentlyRetiredAgentStatusPaneKeys[SIBLING]).toBe(siblingRetirement) store.getState().setAgentStatus(SIBLING, { state: 'working', prompt: 'still fenced' }) expect(store.getState().agentStatusByPaneKey[SIBLING]).toBeUndefined() }) @@ -184,8 +187,8 @@ describe('agent pane authority', () => { expect(state.sleepingAgentSessionsByPaneKey[TARGET]).toMatchObject({ providerSession: { key: 'session_id', id: 'session-1' } }) - expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toBe(true) - expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET) + expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toEqual(expect.any(String)) + expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET, expect.any(String)) }) it('keeps a physical pane routed through chained detaches until its current owner closes', () => { diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index c0d9cae2cb0..7382cc020f2 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -31,8 +31,19 @@ export function createAgentStatusAuthorityActions( scheduleAgentStatusFreshness: () => freshness.schedule(), retireAgentPaneAuthority: (paneKey, options) => { + const retirementId = crypto.randomUUID() const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey) + const previousRetirement = get().recentlyRetiredAgentStatusPaneKeys[ownerPaneKey] + const retiredPaneKeys = [ + ...new Set([ + ...retireAgentPaneAuthorityAliases(paneKey), + ...Object.keys(get().recentlyRetiredAgentStatusPaneKeys).filter( + (key) => + typeof previousRetirement === 'string' && + get().recentlyRetiredAgentStatusPaneKeys[key] === previousRetirement + ) + ]) + ] const retiredPaneKeySet = new Set(retiredPaneKeys) for (const key of retiredPaneKeys) { rendererAgentStatusObservations.forget(key) @@ -97,7 +108,14 @@ export function createAgentStatusAuthorityActions( retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys( s.recentlyRetiredAgentStatusPaneKeys, - retiredPaneKeys + retiredPaneKeys, + s.recentlyRetiredAgentStatusPaneKeys[ownerPaneKey] === true || + isRecentlyClosedAgentStatusTab( + s.recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(ownerPaneKey) + ) + ? true + : retirementId ), agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch @@ -107,7 +125,12 @@ export function createAgentStatusAuthorityActions( freshness.scheduleDeferred() } if (typeof window !== 'undefined') { - window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey) + window.api?.agentStatus?.retirePaneAuthority?.( + ownerPaneKey, + get().recentlyRetiredAgentStatusPaneKeys[ownerPaneKey] === retirementId + ? retirementId + : undefined + ) } }, diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index eaab5bb78ae..1aa19863e25 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -109,6 +109,7 @@ export type AgentStatusRouting = { } export type AgentStatusMetadata = { + authorityRestartId?: string /** Structured status rows remain fresh while the host owns the session; cleared on feed loss. */ structuredHostOwned?: true providerSession?: AgentProviderSessionMetadata diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts index 4b4baca029a..ceaefc34355 100644 --- a/src/renderer/src/store/slices/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -5,6 +5,7 @@ import { boundRecentlyClosedAgentStatusTabIds, boundRecentlyRetiredAgentStatusPaneKeys, removePaneKeys, + closedAgentStatusRetirementKeys, removePaneKeysByTabPrefix } from './agent-status-pane-keyed-records' import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-key-tab-binding' @@ -91,7 +92,11 @@ export function buildAgentStatusTabPrefixDropPatch( : boundRecentlyClosedAgentStatusTabIds(s.recentlyClosedAgentStatusTabIds, tabIdPrefix) const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( s.recentlyRetiredAgentStatusPaneKeys, - retiredAliasPaneKeys + closedAgentStatusRetirementKeys( + s.recentlyRetiredAgentStatusPaneKeys, + prefix, + retiredAliasPaneKeys + ) ) const nextClearedAt = opts?.preserveActivityClearedState ? s.activityClearedAtByPaneKey diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index f825abc9d2c..4bfbe870130 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -1,3 +1,4 @@ +import { resolvePaneKey } from '../../lib/agent-status-pane-ownership' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import type { @@ -6,7 +7,10 @@ import type { AgentStatusRouting, AgentStatusTiming } from './agent-status-contract' -import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' +import { + resolveAgentPaneAuthorityKey, + transferAgentPaneAuthorityAlias +} from './agent-pane-authority' import { buildAgentStatusLiveEntry, type AgentStatusLiveEntryBuild, @@ -46,10 +50,15 @@ export function createAgentStatusLiveActions( metadata?: AgentStatusMetadata ): void => { const paneKey = resolveAgentPaneAuthorityKey(rawPaneKey) + if (metadata?.authorityRestartId && paneKey !== rawPaneKey) { + return + } const updatedAt = timing?.updatedAt ?? Date.now() const current = get() if ( - paneKey in current.recentlyRetiredAgentStatusPaneKeys || + (paneKey in current.recentlyRetiredAgentStatusPaneKeys && + (typeof current.recentlyRetiredAgentStatusPaneKeys[paneKey] !== 'string' || + current.recentlyRetiredAgentStatusPaneKeys[paneKey] !== metadata?.authorityRestartId)) || isRecentlyClosedAgentStatusTab( current.recentlyClosedAgentStatusTabIds, getTabIdFromPaneKey(paneKey) @@ -60,6 +69,30 @@ export function createAgentStatusLiveActions( let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null let liveEntryDelta: FreshnessLiveEntryDelta | null = null set((state) => { + const retirement = state.recentlyRetiredAgentStatusPaneKeys[paneKey] + if ( + (retirement !== undefined && + (typeof retirement !== 'string' || retirement !== metadata?.authorityRestartId)) || + isRecentlyClosedAgentStatusTab( + state.recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(paneKey) + ) + ) { + return state + } + if (retirement !== undefined) { + const owner = resolvePaneKey(state, paneKey) + if ( + !owner.exists || + payload.agentType !== 'omp' || + (routing?.worktreeId !== undefined && routing.worktreeId !== owner.owningWorktreeId) || + (routing?.connectionId !== undefined && + routing.connectionId !== owner.repoConnectionId && + (owner.repoConnectionResolved || routing.worktreeId !== owner.owningWorktreeId)) + ) { + return state + } + } built = buildAgentStatusLiveEntry({ state, paneKey, @@ -82,6 +115,17 @@ export function createAgentStatusLiveActions( replacedEntry: previousEntries[built.entry.paneKey], evictedEntries: reduction.evictedEntries } + if (retirement !== undefined) { + // The host confirmed this retired group’s surviving owner; preserve that route for its next retirement. + for (const [key, id] of Object.entries(state.recentlyRetiredAgentStatusPaneKeys)) { + if (id === retirement && key !== paneKey && resolveAgentPaneAuthorityKey(key) === key) { + transferAgentPaneAuthorityAlias({ fromPaneKey: key, toPaneKey: paneKey }) + } + } + const nextRetired = { ...state.recentlyRetiredAgentStatusPaneKeys } + delete nextRetired[paneKey] + return { ...reduction.patch, recentlyRetiredAgentStatusPaneKeys: nextRetired } + } return reduction.patch }) if (liveEntryDelta) { diff --git a/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts index 3acf5258f2c..1cfebcbddfc 100644 --- a/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts +++ b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts @@ -3,22 +3,23 @@ export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 // delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates // insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. -function boundLruKeyRecord( - existing: Record, +function boundLruKeyRecord( + existing: Record, additions: ReadonlySet, - max: number -): Record { - if (isLruKeyRecordUnchanged(existing, additions, max)) { + max: number, + value: Value +): Record { + if (isLruKeyRecordUnchanged(existing, additions, max, value)) { return existing } - const next: Record = {} + const next: Record = {} for (const key of Object.keys(existing)) { if (!additions.has(key)) { - next[key] = true + next[key] = existing[key] } } for (const key of additions) { - next[key] = true + next[key] = value } const keys = Object.keys(next) for (const stale of keys.slice(0, -max)) { @@ -31,10 +32,11 @@ function boundLruKeyRecord( // already the tail of `existing` in that same relative order. A matching key SET is // not enough: re-adding a key moves it to the tail, and that order decides which key // the cap evicts next, so a stale-order hit would un-fence a recently retired pane. -function isLruKeyRecordUnchanged( - existing: Record, +function isLruKeyRecordUnchanged( + existing: Record, additions: ReadonlySet, - max: number + max: number, + value: Value ): boolean { const keys = Object.keys(existing) if (keys.length > max || additions.size > keys.length) { @@ -42,7 +44,7 @@ function isLruKeyRecordUnchanged( } let index = keys.length - additions.size for (const key of additions) { - if (keys[index++] !== key) { + if (keys[index++] !== key || existing[key] !== value) { return false } } @@ -53,14 +55,25 @@ export function boundRecentlyClosedAgentStatusTabIds( existing: Record, tabId: string ): Record { - return boundLruKeyRecord(existing, new Set([tabId]), RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) + return boundLruKeyRecord( + existing, + new Set([tabId]), + RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX, + true + ) } export function boundRecentlyRetiredAgentStatusPaneKeys( - existing: Record, - paneKeys: readonly string[] -): Record { - return boundLruKeyRecord(existing, new Set(paneKeys), RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX) + existing: Record, + paneKeys: readonly string[], + retirementId: true | string = true +): Record { + return boundLruKeyRecord( + existing, + new Set(paneKeys), + RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX, + retirementId + ) } export function movePaneKeyedRecord( @@ -110,3 +123,27 @@ export function removePaneKeysByTabPrefix( ) return removePaneKeys(record, new Set(matchingKeys)) } + +/** A closed physical key revokes recovery for its whole retired alias group. */ +export function closedAgentStatusRetirementKeys( + existing: Record, + prefix: string, + aliasPaneKeys: readonly string[] +): string[] { + const keys = new Set(aliasPaneKeys) + const ids = new Set() + for (const [key, value] of Object.entries(existing)) { + if (key.startsWith(prefix) || keys.has(key)) { + keys.add(key) + if (typeof value === 'string') { + ids.add(value) + } + } + } + for (const [key, value] of Object.entries(existing)) { + if (typeof value === 'string' && ids.has(value)) { + keys.add(key) + } + } + return [...keys] +} diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index 8dc01fc5598..7e2f9876d3c 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -61,7 +61,7 @@ export type AgentStatusSlice = { recentlyClosedAgentStatusTabIds: Record /** Exact pane authorities retired while sibling panes in the tab stay live. */ - recentlyRetiredAgentStatusPaneKeys: Record + recentlyRetiredAgentStatusPaneKeys: Record retireAgentPaneAuthority: ( paneKey: string, diff --git a/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts b/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts index 5545b3678f6..e44d8295334 100644 --- a/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts +++ b/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts @@ -129,7 +129,7 @@ function collect(args: { terminalHandle?: string previousProviderSession?: AgentStatusEntry['providerSession'] currentProviderSession?: AgentStatusEntry['providerSession'] - retiredPaneKeys?: Record + retiredPaneKeys?: Record tabIndex?: Map }) { return collectRetainedAgentsOnDisappear({ diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 3bff35272c2..a15282b0018 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -37,6 +37,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Fully qualified provider identity; never a credential or mailbox lookup key. */ providerAlias?: AgentStatusProviderAlias paneKey: string + /** Live host acknowledgement of this renderer’s exact pane retirement. */ + authorityRestartId?: string launchToken?: string terminalHandle?: string tabId?: string diff --git a/src/shared/agent-status-legacy-ingress-manifest.ts b/src/shared/agent-status-legacy-ingress-manifest.ts index f70193813d4..f14681a6783 100644 --- a/src/shared/agent-status-legacy-ingress-manifest.ts +++ b/src/shared/agent-status-legacy-ingress-manifest.ts @@ -34,7 +34,7 @@ function entry( export const AGENT_STATUS_LEGACY_INGRESS_MANIFEST = Object.freeze([ entry({ caller: 'main-status-update', - sourcePath: 'src/main/agent-hooks/server/server-status-update.ts', + sourcePath: 'src/main/agent-hooks/server/server-status-application.ts', reason: 'Hook, OSC, and unsupported-peer observations still use pane ownership in 2A.', owner: 'main-agent-hooks', destination: '2B', diff --git a/tests/e2e/omp-retired-host-renderer.unit.test.ts b/tests/e2e/omp-retired-host-renderer.unit.test.ts new file mode 100644 index 00000000000..26205e8c2dc --- /dev/null +++ b/tests/e2e/omp-retired-host-renderer.unit.test.ts @@ -0,0 +1,245 @@ +import { resetAgentPaneAuthorityAliasesForTests } from '../../src/renderer/src/store/slices/agent-pane-authority' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from '../../src/main/agent-hooks/server' +import { toAgentStatusIpcPayload } from '../../src/main/agent-hooks/server/server-status-identity' +import type { AgentStatusIpcPayload } from '../../src/shared/agent-status-types' +import { makePaneKey } from '../../src/shared/stable-pane-id' +import { isAgentStatusForRecentlyClosedTab } from '../../src/renderer/src/hooks/ipc-events/agent-status-routing' +import { createTestStore } from '../../src/renderer/src/store/slices/store-test-helpers' + +vi.mock('../../src/main/telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../src/main/telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +afterEach(() => { + resetAgentPaneAuthorityAliasesForTests() + vi.unstubAllGlobals() + vi.useRealTimers() +}) +const paneKey = makePaneKey('tab-omp', '11111111-1111-4111-8111-111111111111') + +function fixture() { + resetAgentPaneAuthorityAliasesForTests() + vi.useFakeTimers() + const server = new AgentHookServer() + const store = createTestStore() + const emitted: AgentStatusIpcPayload[] = [] + store.setState({ + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-omp', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'OMP', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + } + }) + vi.stubGlobal('window', { + api: { + agentStatus: { + retirePaneAuthority: (key: string, id?: string) => server.retirePaneAuthority(key, id), + dropByTabPrefix: (tabId: string) => server.dropStatusEntriesByTabPrefix(tabId) + } + } + }) + server.setListener((event) => + emitted.push({ + ...toAgentStatusIpcPayload(event), + ...(event.authorityRestartId ? { authorityRestartId: event.authorityRestartId } : {}) + }) + ) + const restart = () => + server.ingestRemote( + { + paneKey, + source: 'omp', + hookEventName: 'before_agent_start', + payload: { agentType: 'omp', state: 'working', prompt: 'new turn' } + }, + null + ) + const apply = (data: AgentStatusIpcPayload, replay = false, batch = false) => { + const id = replay ? undefined : data.authorityRestartId + if (isAgentStatusForRecentlyClosedTab(store.getState(), data.paneKey, id)) { + return + } + const update = { + paneKey: data.paneKey, + payload: data, + timing: { updatedAt: data.receivedAt, stateStartedAt: data.stateStartedAt }, + metadata: id ? { authorityRestartId: id } : undefined + } + if (batch) { + store.getState().setAgentStatuses([update]) + } else { + store + .getState() + .setAgentStatus( + update.paneKey, + update.payload, + undefined, + update.timing, + undefined, + update.metadata + ) + } + } + return { server, store, emitted, restart, apply } +} + +describe('OMP host-authorized renderer retirement recovery', () => { + it.each([false, true])( + 'recovers the matching retirement (batch=%s) without replayable authority', + (batch) => { + const f = fixture() + f.store.getState().retireAgentPaneAuthority(paneKey) + f.restart() + expect(f.emitted).toHaveLength(1) + expect(f.emitted[0].authorityRestartId).toBe( + f.store.getState().recentlyRetiredAgentStatusPaneKeys[paneKey] + ) + f.apply(f.emitted[0], false, batch) + expect(f.store.getState().agentStatusByPaneKey[paneKey]?.state).toBe('working') + expect(f.store.getState().recentlyRetiredAgentStatusPaneKeys[paneKey]).toBeUndefined() + expect(f.server.getStatusSnapshot()[0]).not.toHaveProperty('authorityRestartId') + const replay = vi.fn() + f.server.setListener(replay) + expect(replay.mock.calls[0][0]).not.toHaveProperty('authorityRestartId') + f.server.stop() + } + ) + + it('rejects an earlier restart after a second retirement, then accepts its own restart', () => { + const f = fixture() + f.store.getState().retireAgentPaneAuthority(paneKey) + f.restart() + const old = f.emitted[0] + f.store.getState().retireAgentPaneAuthority(paneKey) + expect(f.store.getState().recentlyRetiredAgentStatusPaneKeys[paneKey]).not.toBe( + old.authorityRestartId + ) + f.apply(old) + expect(f.store.getState().agentStatusByPaneKey[paneKey]).toBeUndefined() + f.restart() + f.apply(f.emitted[1]) + expect(f.store.getState().agentStatusByPaneKey[paneKey]?.state).toBe('working') + f.server.stop() + }) + + it.each(['replay', 'closed', 'missing-pane'])('does not restore on %s', (reason) => { + const f = fixture() + f.store.getState().retireAgentPaneAuthority(paneKey) + f.restart() + if (reason === 'closed') { + f.store.getState().dropAgentStatusByTabPrefix('tab-omp') + } + if (reason === 'missing-pane') { + f.store.setState({ tabsByWorktree: {} }) + } + f.apply(f.emitted[0], reason === 'replay') + expect(f.store.getState().agentStatusByPaneKey[paneKey]).toBeUndefined() + expect(f.store.getState().recentlyRetiredAgentStatusPaneKeys[paneKey]).toBeDefined() + f.server.stop() + }) +}) + +it.each([false, true])( + 'recovers a detached pane at its owner after repeat retirement=%s', + (repeat) => { + const f = fixture() + const owner = makePaneKey('tab-owner', '22222222-2222-4222-8222-222222222222') + f.server.transferPaneAuthority(paneKey, owner, 'pty-1') + f.store + .getState() + .transferAgentPaneAuthority({ fromPaneKey: paneKey, toPaneKey: owner, ptyId: 'pty-1' }) + f.store.setState({ + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-owner', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'OMP', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + } + }) + f.store.getState().retireAgentPaneAuthority(owner) + if (repeat) { + f.store.getState().retireAgentPaneAuthority(owner) + } + expect(f.store.getState().recentlyRetiredAgentStatusPaneKeys[paneKey]).toBe( + f.store.getState().recentlyRetiredAgentStatusPaneKeys[owner] + ) + f.restart() + expect(f.emitted[0]).toMatchObject({ paneKey: owner, tabId: 'tab-owner' }) + f.apply(f.emitted[0]) + expect(f.store.getState().agentStatusByPaneKey[owner]?.state).toBe('working') + expect(f.store.getState().agentStatusByPaneKey[paneKey]).toBeUndefined() + f.server.stop() + } +) + +it('keeps explicit closure after tab-LRU eviction and repeated retirement', () => { + const f = fixture() + f.store.getState().retireAgentPaneAuthority(paneKey) + f.restart() + f.store.getState().dropAgentStatusByTabPrefix('tab-omp') + for (let n = 0; n < 1025; n++) { + f.store.getState().dropAgentStatusByTabPrefix(`other-${n}`) + } + expect(f.store.getState().recentlyClosedAgentStatusTabIds['tab-omp']).toBeUndefined() + f.store.getState().retireAgentPaneAuthority(paneKey) + expect(f.store.getState().recentlyRetiredAgentStatusPaneKeys[paneKey]).toBe(true) + f.apply(f.emitted[0]) + f.restart() + expect(f.emitted).toHaveLength(1) + expect(f.store.getState().agentStatusByPaneKey[paneKey]).toBeUndefined() + f.server.stop() +}) + +it.each([false, true])( + 'preserves detached-group closure across recovery cycles (batch=%s)', + (batch) => { + const f = fixture() + const owner = makePaneKey('tab-owner', '22222222-2222-4222-8222-222222222222') + f.server.transferPaneAuthority(paneKey, owner, 'pty-1') + f.store + .getState() + .transferAgentPaneAuthority({ fromPaneKey: paneKey, toPaneKey: owner, ptyId: 'pty-1' }) + f.store.setState({ + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-owner', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'OMP', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + } + }) + f.store.getState().retireAgentPaneAuthority(owner) + f.restart() + f.apply(f.emitted[0], false, batch) + expect(f.store.getState().agentStatusByPaneKey[owner]?.state).toBe('working') + f.store.getState().retireAgentPaneAuthority(owner) + f.restart() + f.store.getState().dropAgentStatusByTabPrefix('tab-omp') + f.apply(f.emitted[1], false, batch) + expect(f.store.getState().agentStatusByPaneKey[owner]).toBeUndefined() + f.server.stop() + } +)