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() + } +)