mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user