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.
This commit is contained in:
Neil
2026-09-19 08:05:33 -07:00
committed by GitHub
parent e7da72c3d7
commit e8a7be4ce2
38 changed files with 1216 additions and 354 deletions
+24
View File
@@ -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.
@@ -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([])
})
})
@@ -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()
}
})
@@ -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
@@ -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 }
@@ -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,
@@ -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
@@ -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<ReturnType<typeof normalizeAgentProviderSession>>
}
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
}
}
+3 -1
View File
@@ -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<string>,
aliases: readonly RetiredPaneAlias[]
aliases: readonly RetiredPaneAlias[],
retirementId?: string
): void
protected abstract markPaneClosedForAgentStatus(paneKey: string): void
protected abstract attachStatusTiming(
@@ -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(
@@ -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<string>,
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<string, string> = {}
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)
@@ -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
)
}
}
@@ -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<string, string>
}
export type LastStatusFile = {
+1 -1
View File
@@ -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)
})
+20 -10
View File
@@ -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
@@ -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<string, (...args: unknown[]) => 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()
}
)
})
@@ -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({
+5 -1
View File
@@ -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.
@@ -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 } : {}),
@@ -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')
})
+1 -1
View File
@@ -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. */
+2 -2
View File
@@ -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)
@@ -239,7 +239,7 @@ export function collectRetainedAgentsOnDisappear(args: {
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
retentionSuppressedPaneKeys: Record<string, true>
recentlyClosedAgentStatusTabIds: Record<string, true>
recentlyRetiredAgentStatusPaneKeys: Record<string, true>
recentlyRetiredAgentStatusPaneKeys: Record<string, true | string>
/** Live tabs by id; supplies the real destination tab when a pane key transferred. */
tabIndex?: Map<string, { tab: TerminalTab }>
}): {
@@ -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')) {
@@ -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<AppState, 'recentlyClosedAgentStatusTabIds' | 'recentlyRetiredAgentStatusPaneKeys'>,
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<typeof useAppStore.getState>,
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<typeof useAppStore.getState>,
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'
@@ -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 } : {})
}
}
@@ -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
}
}
@@ -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', () => {
@@ -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
)
}
},
@@ -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
@@ -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
@@ -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 groups 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) {
@@ -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<string, true>,
function boundLruKeyRecord<Value extends true | string>(
existing: Record<string, Value>,
additions: ReadonlySet<string>,
max: number
): Record<string, true> {
if (isLruKeyRecordUnchanged(existing, additions, max)) {
max: number,
value: Value
): Record<string, Value> {
if (isLruKeyRecordUnchanged(existing, additions, max, value)) {
return existing
}
const next: Record<string, true> = {}
const next: Record<string, Value> = {}
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<string, true>,
function isLruKeyRecordUnchanged<Value extends true | string>(
existing: Record<string, Value>,
additions: ReadonlySet<string>,
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<string, true>,
tabId: string
): Record<string, true> {
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<string, true>,
paneKeys: readonly string[]
): Record<string, true> {
return boundLruKeyRecord(existing, new Set(paneKeys), RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)
existing: Record<string, true | string>,
paneKeys: readonly string[],
retirementId: true | string = true
): Record<string, true | string> {
return boundLruKeyRecord(
existing,
new Set(paneKeys),
RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX,
retirementId
)
}
export function movePaneKeyedRecord<T>(
@@ -110,3 +123,27 @@ export function removePaneKeysByTabPrefix<T>(
)
return removePaneKeys(record, new Set(matchingKeys))
}
/** A closed physical key revokes recovery for its whole retired alias group. */
export function closedAgentStatusRetirementKeys(
existing: Record<string, true | string>,
prefix: string,
aliasPaneKeys: readonly string[]
): string[] {
const keys = new Set(aliasPaneKeys)
const ids = new Set<string>()
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]
}
@@ -61,7 +61,7 @@ export type AgentStatusSlice = {
recentlyClosedAgentStatusTabIds: Record<string, true>
/** Exact pane authorities retired while sibling panes in the tab stay live. */
recentlyRetiredAgentStatusPaneKeys: Record<string, true>
recentlyRetiredAgentStatusPaneKeys: Record<string, true | string>
retireAgentPaneAuthority: (
paneKey: string,
@@ -129,7 +129,7 @@ function collect(args: {
terminalHandle?: string
previousProviderSession?: AgentStatusEntry['providerSession']
currentProviderSession?: AgentStatusEntry['providerSession']
retiredPaneKeys?: Record<string, true>
retiredPaneKeys?: Record<string, true | string>
tabIndex?: Map<string, { tab: TerminalTab }>
}) {
return collectRetainedAgentsOnDisappear({
+2
View File
@@ -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 renderers exact pane retirement. */
authorityRestartId?: string
launchToken?: string
terminalHandle?: string
tabId?: string
@@ -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',
@@ -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()
}
)