diff --git a/docs/reference/agent-status-canonical-boundary.md b/docs/reference/agent-status-canonical-boundary.md new file mode 100644 index 00000000000..e6968e9978c --- /dev/null +++ b/docs/reference/agent-status-canonical-boundary.md @@ -0,0 +1,61 @@ +# Canonical agent-status boundary + +## PR 2A production ownership + +The hook server owns one canonical store keyed by the full agent-status subject. +Structured-session admission gets that subject from the owning session's trusted +execution location, not from a pane key or a renderer payload. The feed retains +the exact subject for teardown even when the record and live session have already +been deleted. Desktop/runtime service and `orcad` install the same sink. + +Canonical structured rows are never copied into the legacy adapter or persisted +in `last-status.json`. Their journal and session record remain durable authority. +Legacy snapshots and notifications project the committed canonical row. Combined +listing with the adapter preserves first-insertion ordering; routing indexes hold +only subjects and ordering metadata, never another status row. + +PTY hooks, OSC, hydrated rows and SSH relay evidence do not yet carry trusted full +execution scope. They remain in the isolated legacy adapter, with its existing +process, replay and receiver fences. Do not invent missing host, distro or workspace +kind fields. A manifested legacy writer cannot overwrite an address already owned +by a canonical row; canonical admission also refuses an occupied legacy address. +Neither direction guesses that two independently addressed observations are one owner. + +## Contract without premature serving + +The shared mutation core commits parents, child work, aliases, facts and tombstones +at one revision. Child identity is host-minted; provider task/tool identifiers are +scoped aliases, not authorization. Updates and stops require the expected invocation. +Aliases retain multiple lifetime bindings; retired bindings fence late observations +after child removal, reclassification, reparenting, or bounded invocation-history decay. +Snapshot restoration precedes replay; the store does not compare revisions across +unrelated owner epochs. + +Record counts, invocation history and complete serialized snapshots have named limits. +A mutation that would exceed them fails atomically; projection truncation never +silently evicts owner history. Admission reads bounded child/alias queries without +cloning the complete snapshot. + +Canonical child records preserve all existing kinds, states, live/settled membership, +outcomes and optional metadata. Legacy subagents are a bounded agent-only projection; +their required `startedAt` comes from host `firstObservedAt`. Projection limits do +not evict canonical records. One child-freshness rule downgrades active evidence to +`unverifiable` when either parent evidence is stale or transport is unverifiable; +neither condition proves completion or settlement. + +The required Vitest suite enforces the current-producer legacy allowlist and the +empty-before-advertise gate. The complete run/child serving contract is not advertised +while current producers still depend on that adapter. Capable-host legacy ingress +is refused, not treated as a fallback writer. + +## Remaining cutovers + +- PR 2B binds trusted PTY/relay scope and hands ownership over atomically, retiring + current producers from the legacy manifest. +- PR 2C admits full provider child observations before lossy summaries and supplies + structured turn/completion clocks. +- Reader cutover requires whole-row delivery parity and capability negotiation. + Until then, keep `StructuredAgentSessionStatusBridge` and the main structured-row + publication filters: the bridge still supplies the live native-chat children. +- Pane-only old clients retain their existing successor-fact limitations. A legacy + projection does not grant those clients canonical identity or action authority. diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 8f1439c2122..b6f2e9b3ba8 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -2,8 +2,14 @@ ## Status -Proposed on 2026-09-09 as the follow-up to #19217. It lands in four steps, in -this order, each independently shippable: +The current boundary is PR 2A: structured sessions use the hook server's fully +scoped canonical store; unbound PTY/relay evidence remains in an isolated legacy +adapter. See [the canonical boundary](./agent-status-canonical-boundary.md). +Do not remove the renderer bridge or its publication filters in this slice: +they still carry native-chat child rows. + +The sections below record the original 2026-09-09 rollout. Its PR 1a and PR 1b +have landed; its proposed PR 2/3 sequence is superseded by the canonical boundary: 1. main-only: every producer writes into one store and `worktree ps` reads it, split into 1a (structured sessions join the store) and 1b (the runtime's @@ -11,9 +17,6 @@ this order, each independently shippable: 2. renderer: the sidebar becomes a subscriber and stops re-deriving rows; 3. shared: one worktree-status rollup and one freshness rule for every reader. -The PR that carries this document is PR 1a. Sections below are grouped under -the step that delivers them; PR 1a and PR 1b have landed. - ## The problem this solves Orca shows "what is this agent doing" in four places: the desktop sidebar, the diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 8ecd4997aa0..6332a4c23f6 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -118,7 +118,21 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ - ['session', { journal, params: { location: { workspaceId }, provider: agent } }] + [ + 'session', + { + journal, + params: { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId, + workspaceKind: 'git-worktree' + }, + provider: agent + } + } + ] ]), getRecord: () => null, now: () => 1, @@ -193,7 +207,12 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { ] }) } as unknown as AgentSessionJournal - const location = { workspaceId, workspaceKind: 'git-worktree' as const } + const location = { + executionHostId: 'local' as const, + wslDistro: null, + workspaceId, + workspaceKind: 'git-worktree' as const + } const pending: Promise[] = [] const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]), diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index 5b43cc10bce..55a130b5787 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -1,3 +1,4 @@ +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -24,6 +25,15 @@ vi.mock('../telemetry/cohort-classifier', () => ({ })) const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'repo-1::/workspace/app', + workspaceKind: 'git-worktree' + }, + SESSION +) const TAB = structuredAgentSessionTabId(SESSION) const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION) const OBSERVED_AT = 1_757_030_400_000 @@ -59,7 +69,7 @@ afterEach(() => { describe('AgentHookServer ingestStructuredStatus', () => { it('stores the projection as a row under the pane key the renderer derives', () => { const server = new AgentHookServer() - server.ingestStructuredStatus(summary()) + server.ingestStructuredStatus(summary(), SUBJECT) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ @@ -86,22 +96,25 @@ describe('AgentHookServer ingestStructuredStatus', () => { // The same mapping the sidebar applies, so the two surfaces cannot disagree about one session. it('maps attention to blocked and idle to done', () => { const server = new AgentHookServer() - server.ingestStructuredStatus(summary({ status: 'attention' })) + server.ingestStructuredStatus(summary({ status: 'attention' }), SUBJECT) expect(server.getStatusSnapshot()[0]?.state).toBe('blocked') - server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 })) + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }), SUBJECT) expect(server.getStatusSnapshot()[0]?.state).toBe('done') }) it('marks a session whose provider child is gone as held, not owned', () => { const server = new AgentHookServer() - server.ingestStructuredStatus(summary({ hostExecutionOwned: undefined })) + server.ingestStructuredStatus(summary({ hostExecutionOwned: undefined }), SUBJECT) expect(server.getStatusSnapshot()[0]?.structuredHost).toBe('held') }) it('keeps the state start while later evidence of the same state arrives', () => { const server = new AgentHookServer() - server.ingestStructuredStatus(summary()) - server.ingestStructuredStatus(summary({ toolName: 'read', updatedAt: OBSERVED_AT + 5_000 })) + server.ingestStructuredStatus(summary(), SUBJECT) + server.ingestStructuredStatus( + summary({ toolName: 'read', updatedAt: OBSERVED_AT + 5_000 }), + SUBJECT + ) expect(server.getStatusSnapshot()[0]).toMatchObject({ toolName: 'read', @@ -113,18 +126,18 @@ describe('AgentHookServer ingestStructuredStatus', () => { // Null status means no turn has been persisted; the chat shows nothing, so neither does this. it('holds no row for a session without a persisted turn, and drops one that regresses to none', () => { const server = new AgentHookServer() - server.ingestStructuredStatus(summary({ status: null })) + server.ingestStructuredStatus(summary({ status: null }), SUBJECT) expect(server.getStatusSnapshot()).toEqual([]) - server.ingestStructuredStatus(summary()) - server.ingestStructuredStatus(summary({ status: null })) + server.ingestStructuredStatus(summary(), SUBJECT) + server.ingestStructuredStatus(summary({ status: null }), SUBJECT) expect(server.getStatusSnapshot()).toEqual([]) }) it('drops the row when the host stops holding the session', () => { const server = new AgentHookServer() - server.ingestStructuredStatus(summary()) - server.dropStructuredStatus(SESSION) + server.ingestStructuredStatus(summary(), SUBJECT) + server.dropStructuredStatus(SUBJECT) expect(server.getStatusSnapshot()).toEqual([]) }) @@ -136,13 +149,13 @@ describe('AgentHookServer ingestStructuredStatus', () => { const withProviderSession = summary({ providerSession: { key: 'session_id', id: 'codex-thread-1' } }) - server.ingestStructuredStatus(withProviderSession) + server.ingestStructuredStatus(withProviderSession, SUBJECT) expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({ key: 'session_id', id: 'codex-thread-1' }) - server.dropStructuredStatus(SESSION) + server.dropStructuredStatus(SUBJECT) expect(server.getStatusSnapshot()).toEqual([]) }) @@ -157,8 +170,8 @@ describe('AgentHookServer ingestStructuredStatus', () => { server.setPaneStatusClearListener((clear) => cleared.push(clear)) server.subscribeStatusDrop((paneKey) => dropped.push(paneKey)) - server.ingestStructuredStatus(summary()) - server.dropStructuredStatus(SESSION) + server.ingestStructuredStatus(summary(), SUBJECT) + server.dropStructuredStatus(SUBJECT) expect(server.getStatusSnapshot()).toEqual([]) expect(cleared).toEqual([]) @@ -175,7 +188,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { original() } - server.ingestStructuredStatus(summary()) + server.ingestStructuredStatus(summary(), SUBJECT) expect(persists).toHaveLength(0) server.ingestTerminalStatus({ @@ -193,7 +206,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { connectionId: null, payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } }) - server.ingestStructuredStatus(summary()) + server.ingestStructuredStatus(summary(), SUBJECT) const byPane = new Map(server.getStatusSnapshot().map((row) => [row.paneKey, row])) expect(byPane.get(PANE)?.structuredHost).toBeUndefined() @@ -227,7 +240,7 @@ describe('structured rows and last-status.json', () => { connectionId: null, payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } }) - server.ingestStructuredStatus(summary()) + server.ingestStructuredStatus(summary(), SUBJECT) server.flushStatusPersistSync() } finally { server.stop() diff --git a/src/main/agent-hooks/server-structured-canonical-status.test.ts b/src/main/agent-hooks/server-structured-canonical-status.test.ts new file mode 100644 index 00000000000..239174ecc71 --- /dev/null +++ b/src/main/agent-hooks/server-structured-canonical-status.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + makeStructuredAgentStatusSubject, + type AgentStatusExecutionScope, + type AgentStatusStructuredSessionSubject +} from '../../shared/agent-status-subject' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { makePaneKey } from '../../shared/stable-pane-id' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import { AgentHookServer } from './server' +import { GOOD_PANE, PANE } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) + +const SESSION = 'canonical-session-one' +const SCOPE: AgentStatusExecutionScope = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-one', + workspaceKind: 'git-worktree' +} +const SUBJECT = makeStructuredAgentStatusSubject(SCOPE, SESSION) +const PANE_KEY = structuredAgentSessionPaneKey(structuredAgentSessionTabId(SESSION), SESSION) + +function summary( + subject: AgentStatusStructuredSessionSubject = SUBJECT +): AgentSessionStatusSummary { + return { + sessionId: subject.sessionId, + workspaceId: subject.workspaceId, + agent: 'codex', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'trusted journal', + updatedAt: 100 + } +} + +function terminal(server: AgentHookServer, paneKey: string): void { + server.ingestTerminalStatus({ + paneKey, + worktreeId: SCOPE.workspaceId, + connectionId: null, + payload: { state: 'working', prompt: 'legacy PTY', agentType: 'claude' } + }) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('structured canonical production slice', () => { + it('stores once canonically and supplies every legacy reader from that row', () => { + const server = new AgentHookServer() + const changed = vi.fn() + const enriched = vi.fn() + server.subscribeStatusChanges(changed) + server.subscribeEnrichedStatus(enriched) + server.ingestStructuredStatus(summary(), SUBJECT) + expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0) + expect(server.getCanonicalStatusSnapshot().parents).toEqual([ + expect.objectContaining({ + subject: SUBJECT, + status: expect.objectContaining({ paneKey: PANE_KEY }) + }) + ]) + expect(server.getStatusSnapshotForPane(PANE_KEY)).toEqual(server.getStatusSnapshot()) + expect(changed).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ + paneKey: PANE_KEY, + state: 'working', + observedInCurrentRuntime: true + }) + ]) + expect(enriched).toHaveBeenCalledOnce() + const replay = vi.fn() + server.setListener(replay) + expect(replay).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ paneKey: PANE_KEY, isReplay: true }) + ) + }) + + it('keeps mixed legacy enumeration in original insertion order through updates and re-admission', () => { + vi.spyOn(Date, 'now').mockReturnValue(200) + const server = new AgentHookServer() + const second = makeStructuredAgentStatusSubject(SCOPE, 'canonical-session-two') + const secondPane = structuredAgentSessionPaneKey( + structuredAgentSessionTabId(second.sessionId), + second.sessionId + ) + const baseline = new Map() + terminal(server, PANE) + baseline.set(PANE, 'legacy PTY') + server.ingestStructuredStatus(summary(), SUBJECT) + baseline.set(PANE_KEY, 'trusted journal') + terminal(server, GOOD_PANE) + baseline.set(GOOD_PANE, 'legacy PTY') + server.ingestStructuredStatus(summary(second), second) + baseline.set(secondPane, 'trusted journal') + terminal(server, PANE) + server.ingestStructuredStatus({ ...summary(), latestPrompt: 'updated' }, SUBJECT) + baseline.set(PANE_KEY, 'updated') + const listing = () => server.getStatusSnapshot().map((row) => [row.paneKey, row.prompt]) + expect(listing()).toEqual([...baseline]) + expect(server.getStatusChangeSnapshot().map((row) => row.paneKey)).toEqual([...baseline.keys()]) + const replay: string[] = [] + server.setListener((entry) => replay.push(entry.paneKey)) + expect(replay).toEqual([...baseline.keys()]) + server.dropStructuredStatus(SUBJECT) + baseline.delete(PANE_KEY) + server.ingestStructuredStatus(summary(), SUBJECT) + baseline.set(PANE_KEY, 'trusted journal') + expect(listing()).toEqual([...baseline]) + const relocated = makePaneKey('relocated-tab', '88888888-8888-4888-8888-888888888888') + server.transferPaneAuthority(PANE, relocated, undefined, 200, { authorityVerified: true }) + baseline.delete(PANE) + baseline.set(relocated, 'legacy PTY') + expect(listing()).toEqual([...baseline]) + expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(2) + expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(2) + }) + + it('isolates identical session identifiers across host, WSL and workspace kind scopes', () => { + const server = new AgentHookServer() + const scopes: AgentStatusExecutionScope[] = [ + SCOPE, + { ...SCOPE, wslDistro: 'Ubuntu' }, + { ...SCOPE, wslDistro: 'Debian' }, + { ...SCOPE, executionHostId: 'ssh:first' }, + { ...SCOPE, executionHostId: 'ssh:second' }, + { ...SCOPE, executionHostId: 'runtime:paired' }, + { ...SCOPE, workspaceKind: 'folder' } + ] + const subjects = scopes.map((scope) => makeStructuredAgentStatusSubject(scope, SESSION)) + for (const subject of subjects) { + server.ingestStructuredStatus(summary(subject), subject) + } + expect(server.getCanonicalStatusSnapshot().parents.map((row) => row.subject)).toEqual(subjects) + server.dropStructuredStatus(SUBJECT) + expect(server.getCanonicalStatusSnapshot().parents.map((row) => row.subject)).toEqual( + subjects.slice(1) + ) + expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0) + }) + + it('rejects missing or mismatched structured scope without fabricating a parent', () => { + const server = new AgentHookServer() + expect(() => Reflect.apply(server.ingestStructuredStatus, server, [summary()])).toThrow( + 'trusted owner subject' + ) + expect(() => + server.ingestStructuredStatus({ ...summary(), workspaceId: 'other' }, SUBJECT) + ).toThrow('trusted owner subject') + expect(server.getCanonicalStatusSnapshot().parents).toEqual([]) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('refuses late PTY and relay evidence at a canonically owned address without fanout', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary(), SUBJECT) + const before = server.getCanonicalStatusSnapshot() + const changed = vi.fn() + const enriched = vi.fn() + server.subscribeStatusChanges(changed) + server.subscribeEnrichedStatus(enriched) + terminal(server, PANE_KEY) + server.ingestRemote( + { paneKey: PANE_KEY, payload: { state: 'done', prompt: 'late', agentType: 'claude' } }, + 'ssh-route' + ) + expect(server.getCanonicalStatusSnapshot()).toEqual(before) + expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0) + expect(server.getStatusSnapshot()).toHaveLength(1) + expect(changed).not.toHaveBeenCalled() + expect(enriched).not.toHaveBeenCalled() + }) + + it('refuses a canonical address already occupied by unbound legacy evidence', () => { + const server = new AgentHookServer() + terminal(server, PANE_KEY) + expect(() => server.ingestStructuredStatus(summary(), SUBJECT)).toThrow( + 'conflicts with legacy evidence' + ) + expect(server.getCanonicalStatusSnapshot().parents).toEqual([]) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE_KEY, prompt: 'legacy PTY' }) + ]) + }) + + it('keeps incomplete remote evidence exclusively legacy and pane cleanup cannot remove a canonical row', () => { + const server = new AgentHookServer() + server.ingestRemote( + { paneKey: PANE, payload: { state: 'working', prompt: 'remote', agentType: 'claude' } }, + 'ssh-route' + ) + expect(server.getCanonicalStatusSnapshot().parents).toEqual([]) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + connectionId: 'ssh-route', + paneKey: PANE + }) + server.ingestStructuredStatus(summary(), SUBJECT) + server.dropStatusEntry(PANE_KEY) + server.retirePaneAuthority(PANE_KEY) + expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(1) + expect(server.getStatusSnapshotForPane(PANE_KEY)).toHaveLength(1) + }) + + it('clears canonical state and renews the owner epoch when the server stops', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary(), SUBJECT) + const epoch = server.getCanonicalStatusSnapshot().epoch + server.stop() + expect(server.getCanonicalStatusSnapshot().parents).toEqual([]) + expect(server.getCanonicalStatusSnapshot().epoch).not.toBe(epoch) + expect(server.getStatusSnapshot()).toEqual([]) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 3fb3f51f5f1..b2aedfc2dfc 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -42,6 +42,7 @@ export const _internals = { parseFormEncodedBody, resetCachesForTests: (): void => { clearAllListenerCaches(agentHookServer._getStateForTests()) + agentHookServer._resetCanonicalStatusForTests() agentHookServer._resetRowOwnershipForTests() agentHookServer._resetPromptSentDedupeForTests() agentHookServer._resetConnectionTimestampWatermarksForTests() diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 45b0e5c0015..19d42913a78 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -1,30 +1,55 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import { + parseAgentStatusSubject, + serializeAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../../shared/agent-status-subject' import { structuredAgentSessionPaneKey, structuredAgentSessionStatusState, structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { structuredStatusLegacyEvent } from './server-structured-status-row' import { AgentHookServerIngestTerminal } from './server-ingest-terminal' -/** - * Structured (native chat) sessions have no PTY and no hook script, so nothing else reaches this - * store for them. The host projects each session's journal into a summary; this is where that - * summary becomes the same row every other agent has, keyed by the pane key the renderer derives. - */ export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal { - ingestStructuredStatus(summary: AgentSessionStatusSummary): void { - const paneKey = structuredStatusPaneKey(summary.sessionId) - // No persisted turn yet: the chat shows nothing, so neither does any status reader. + ingestStructuredStatus( + summary: AgentSessionStatusSummary, + subject: AgentStatusStructuredSessionSubject + ): void { + const parsed = parseAgentStatusSubject(subject) + if ( + !parsed || + parsed.kind !== 'structured-session' || + parsed.sessionId !== summary.sessionId || + parsed.workspaceId !== summary.workspaceId || + !Number.isFinite(summary.updatedAt) || + summary.updatedAt < 0 + ) { + throw new Error('Structured status does not match its trusted owner subject') + } if (!summary.status) { - this.dropStructuredStatus(summary.sessionId) + this.dropStructuredStatus(parsed) return } - if (this.getAgentStatusDisposition(paneKey) !== 'accept') { - return + const previous = this.canonicalStatusStore.getParent(parsed) + const priorStatus = previous?.status + const state = structuredAgentSessionStatusState(summary.status) + const tabId = structuredAgentSessionTabId(parsed.sessionId) + const paneKey = structuredAgentSessionPaneKey(tabId, parsed.sessionId) + if (this.state.lastStatusByPaneKey.has(paneKey)) { + throw new Error('Structured status address conflicts with legacy evidence') } - const payload: ParsedAgentStatusPayload = { - state: structuredAgentSessionStatusState(summary.status), + const snapshot = this.canonicalStatusStore.getSnapshot() + const status: AgentStatusIpcPayload = { + paneKey, + tabId, + worktreeId: parsed.workspaceId, + connectionId: null, + structuredHost: summary.hostExecutionOwned ? 'owned' : 'held', + ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), + state, prompt: summary.latestPrompt, agentType: summary.agent, ...(summary.model ? { model: summary.model } : {}), @@ -32,35 +57,71 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng ...(summary.toolInput ? { toolInput: summary.toolInput } : {}), ...(summary.lastAssistantMessage ? { lastAssistantMessage: summary.lastAssistantMessage } - : {}) + : {}), + receivedAt: Math.max(Date.now(), priorStatus?.receivedAt ?? 0), + evidenceObservedAt: summary.updatedAt, + stateStartedAt: priorStatus?.state === state ? priorStatus.stateStartedAt : summary.updatedAt, + observation: { + origin: 'structured', + kind: 'transition', + authorityId: snapshot.epoch, + incarnation: 0, + revision: snapshot.revision + 1, + observedAt: summary.updatedAt + } } - // The journal clock stamps the evidence so a restart's republish does not read as fresh work. - this.applyNormalizedStatus( - { - paneKey, - tabId: structuredAgentSessionTabId(summary.sessionId), - worktreeId: summary.workspaceId, - connectionId: null, - structuredHost: summary.hostExecutionOwned ? 'owned' : 'held', - ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), - payload - }, - undefined, - 'structured', - summary.updatedAt - ) + const publication = this.canonicalStatusStore.applyMutation({ + parent: { subject: parsed, status, firstObservedAt: previous?.firstObservedAt ?? Date.now() } + }) + if (!publication) { + return + } + const key = serializeAgentStatusSubject(parsed) + const subjects = + this.canonicalSubjectsByPane.get(paneKey) ?? + new Map() + subjects.set(key, parsed) + this.canonicalSubjectsByPane.set(paneKey, subjects) + if (!this.canonicalListingOrder.has(key)) { + this.canonicalListingOrder.set(key, this.nextStatusListingOrder()) + } + const committed = this.canonicalStatusStore.getParent(parsed)?.status + if (!committed) { + throw new Error('Committed structured status is missing') + } + const after = structuredStatusLegacyEvent(committed) + this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after) + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(after) } - /** The host no longer holds the session; its last projection is history the journal keeps. - * `dropStatusEntry`, not `clearPaneState`: the renderer's own bridge still owns this pane key, - * so a pane-status-clear would make main a second writer for it. */ - dropStructuredStatus(sessionId: string): void { - this.dropStatusEntry(structuredStatusPaneKey(sessionId), { preserveResumeIdentity: false }) + /** Pane cleanup never resolves a canonical subject; only its owning feed can forget this row. */ + dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void { + const parsed = parseAgentStatusSubject(subject) + if (!parsed || parsed.kind !== 'structured-session') { + throw new Error('Structured status removal requires its exact owner subject') + } + const previous = this.canonicalStatusStore.getParent(parsed) + if (!previous) { + return + } + const publication = this.canonicalStatusStore.applyMutation({ + removeParent: parsed + }) + if (!publication) { + return + } + const key = serializeAgentStatusSubject(parsed) + this.canonicalListingOrder.delete(key) + if (previous.status) { + const subjects = this.canonicalSubjectsByPane.get(previous.status.paneKey) + subjects?.delete(key) + if (subjects?.size === 0) { + this.canonicalSubjectsByPane.delete(previous.status.paneKey) + } + this.commitStatusRowMutation(structuredStatusLegacyEvent(previous.status), undefined) + this.notifyStatusChangeListeners() + this.emitStatusDropped(previous.status.paneKey) + } } } - -// The DERIVED pane key the renderer publishes, never the orchestration bearer handle or the minted -// worker pane key: both of those are credentials. -function structuredStatusPaneKey(sessionId: string): string { - return structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) -} diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e7f68829f3b..9964331a086 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -116,8 +116,10 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) - this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + if (enriched) { + this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + } } res.writeHead(204) res.end() @@ -212,6 +214,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.ownerStateInitialized = false // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. clearAllListenerCaches(this.state) + this.resetCanonicalStatus() this.notifyStatusChangeListeners() this.paneStatusClearListeners.clear() this.statusDropListeners.clear() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 44e2c940753..60781aef03f 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -4,7 +4,10 @@ import type { } from '../../../shared/agent-status-types' import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference' -import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import { + getLegacyStatusListingOrder, + type HookListenerState +} from '../../../shared/agent-hook-listener/listener-state' import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, @@ -15,8 +18,49 @@ import type { } from './server-types' import { toAgentStatusIpcPayload } from './server-status-identity' import { AgentHookServerState } from './server-state' +import { serializeAgentStatusSubject } from '../../../shared/agent-status-subject' +import { structuredStatusLegacyEvent } from './server-structured-status-row' export abstract class AgentHookServerListeners extends AgentHookServerState { + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + this.onAgentStatus?.(enriched) + for (const listener of this.enrichedStatusListeners) { + try { + listener(enriched) + } catch (err) { + console.error('[agent-hooks] enriched status listener threw', err) + } + } + } + + getCanonicalStatusSnapshot() { + return this.canonicalStatusStore.getSnapshot() + } + + _resetCanonicalStatusForTests(): void { + this.resetCanonicalStatus() + } + + private combinedStatusEntries(): EnrichedAgentHookEventPayload[] { + const rows: { entry: EnrichedAgentHookEventPayload; order: number }[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + rows.push({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; shared listeners expose only the base event type. + entry: entry as EnrichedAgentHookEventPayload, + order: getLegacyStatusListingOrder(this.state, paneKey) ?? 0 + }) + } + for (const parent of this.canonicalStatusStore.getSnapshot().parents) { + if (!parent.status) { + continue + } + rows.push({ + entry: structuredStatusLegacyEvent(parent.status), + order: this.canonicalListingOrder.get(serializeAgentStatusSubject(parent.subject)) ?? 0 + }) + } + return rows.sort((a, b) => a.order - b.order).map(({ entry }) => entry) + } /** * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). * Why: the listener fails open on every request error, so without this the only symptom is @@ -34,10 +78,9 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { return } // Why: replay is best-effort per pane so one throwing listener can't starve the rest. - for (const payload of this.state.lastStatusByPaneKey.values()) { + for (const payload of this.combinedStatusEntries()) { try { - // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. - listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) + listener({ ...payload, isReplay: true }) } catch (err) { console.error('[agent-hooks] replay listener threw', err) } @@ -153,9 +196,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the * dashboard catches up on hook events that fired during startup. */ getStatusSnapshot(): AgentStatusIpcPayload[] { - return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => - toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) - ) + return this.combinedStatusEntries().map(toAgentStatusIpcPayload) } /** Provider-session identities, including Pi's metadata-only rows. */ @@ -164,8 +205,19 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { - const entry = this.state.lastStatusByPaneKey.get(paneKey) - return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] + const legacy = this.state.lastStatusByPaneKey.get(paneKey) + if (legacy) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + return [toAgentStatusIpcPayload(legacy as EnrichedAgentHookEventPayload)] + } + const rows: AgentStatusIpcPayload[] = [] + for (const subject of this.canonicalSubjectsByPane.get(paneKey)?.values() ?? []) { + const status = this.canonicalStatusStore.getParent(subject)?.status + if (status) { + rows.push(status) + } + } + return rows } getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { @@ -184,8 +236,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } { const statuses: AgentHookStatusChangeEntry[] = [] const providerSessions: AgentHookProviderSessionIdentity[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload + for (const enriched of this.combinedStatusEntries()) { + const paneKey = enriched.paneKey if (enriched.providerSession) { providerSessions.push({ paneKey, @@ -201,7 +253,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { paneKey, state: enriched.payload.state, receivedAt: enriched.receivedAt, - observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) + observedInCurrentRuntime: + Boolean(enriched.structuredHost) || this.runtimeObservedStatusPaneKeys.has(paneKey) }) } } diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 0df8ff70445..47d147fe3f2 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -1,8 +1,9 @@ import type { createServer } from 'node:http' -import { randomBytes } from 'node:crypto' +import { randomBytes, randomUUID } from 'node:crypto' import { createHookListenerState, + canAdmitLegacyAgentStatusEntry, type HookListenerState } from '../../../shared/agent-hook-listener/listener-state' import { @@ -21,6 +22,9 @@ import type { AgentHookSource } from '../../../shared/agent-hook-relay' import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' import type { SpoolRecord } from '../../../shared/agent-hook-spool' +import { createAgentStatusStore } from '../../../shared/agent-status-store' +import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' +import type { AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, @@ -45,6 +49,32 @@ import type { /** Shared mutable state for the layered hook-server implementation. */ export abstract class AgentHookServerState { + protected canWriteLegacyStatusRow(entry: AgentHookEventPayload): boolean { + return canAdmitLegacyAgentStatusEntry( + this.state, + 'main-status-update', + entry, + AGENT_STATUS_2A_CURRENT_PRODUCER_MODE + ) + } + + protected canonicalStatusStore = createAgentStatusStore({ + epoch: randomUUID(), + mode: 'authority' + }) + protected readonly canonicalListingOrder = new Map() + protected readonly canonicalSubjectsByPane = new Map< + string, + Map + >() + private statusListingOrder = 0 + protected nextStatusListingOrder = (): number => ++this.statusListingOrder + + protected resetCanonicalStatus(): void { + this.canonicalStatusStore = createAgentStatusStore({ epoch: randomUUID(), mode: 'authority' }) + this.canonicalListingOrder.clear() + this.canonicalSubjectsByPane.clear() + } protected server: ReturnType | null = null protected port = 0 protected token = '' @@ -73,7 +103,10 @@ export abstract class AgentHookServerState { protected endpointFilePathCache: string | null = null protected endpointFileWritten = false // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. - protected state: HookListenerState = createHookListenerState() + protected state: HookListenerState = createHookListenerState({ + nextListingOrder: this.nextStatusListingOrder, + isCanonicalPaneKey: (paneKey) => this.canonicalSubjectsByPane.has(paneKey) + }) protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = null protected transportInterference = createHookTransportInterferenceTracker( @@ -169,7 +202,7 @@ export abstract class AgentHookServerState { origin?: AgentStatusObservationOrigin, observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload - ): EnrichedAgentHookEventPayload + ): EnrichedAgentHookEventPayload | undefined protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void protected abstract clearAssistantMessageRetry(paneKey: string): void protected abstract clearCodexSubagentPoll(paneKey: string): void diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 49575fe223e..3f57e8d87d2 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -111,6 +111,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) + if (!inferred) { + return false + } console.debug('[agent-hooks] inferred interrupted agent status', { paneKey: inferred.paneKey, agentType, @@ -172,6 +175,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) + if (!inferred) { + return false + } console.debug('[agent-hooks] inferred resolved question status', { paneKey: inferred.paneKey, state: inferred.payload.state diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 2757806b293..4620506b210 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -77,7 +77,9 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus const subagentsChanged = JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original - this.scheduleCodexSubagentPoll(source, body, next) + if (next) { + this.scheduleCodexSubagentPoll(source, body, next) + } } protected scheduleAssistantMessageRetry( diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 04325e26bf5..6adfd9430af 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -29,7 +29,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA origin: AgentStatusObservationOrigin = 'hook', observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload - ): EnrichedAgentHookEventPayload { + ): EnrichedAgentHookEventPayload | undefined { + if (!this.canWriteLegacyStatusRow(payload)) { + return undefined + } if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) @@ -72,7 +75,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - this.writeLegacyStatusRow(enriched) + if (!this.writeLegacyStatusRow(enriched)) { + return undefined + } this.commitStatusRowMutation(rowBefore, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() @@ -125,7 +130,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (boundaryReconciledPrevious !== previous) { previous = boundaryReconciledPrevious if (previous) { - this.writeLegacyStatusRow(previous) + if (!this.writeLegacyStatusRow(previous)) { + return undefined + } this.scheduleStatusPersist() } } @@ -224,7 +231,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } else { this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) } - this.writeLegacyStatusRow(enriched) + if (!this.writeLegacyStatusRow(enriched)) { + return undefined + } this.commitStatusRowMutation(rowBefore, enriched) // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify // can only ever reproduce the last file — once per debounce window for a streaming chat. @@ -241,6 +250,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA mutationBefore?: EnrichedAgentHookEventPayload, emitEnrichedStatus = false ): void { + if (!this.canWriteLegacyStatusRow(previous)) { + return + } const connectionClearWatermark = previous.connectionId ? this.connectionTimestampWatermarkById.get(previous.connectionId) : undefined @@ -266,7 +278,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) - this.writeLegacyStatusRow(refreshed) + 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 @@ -291,21 +305,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } } - // Why: every status emit must reach plugins too, so a new early-return path - // upstream cannot silently leave the plugin tap behind the main-window fanout. - protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { - this.onAgentStatus?.(enriched) - for (const listener of this.enrichedStatusListeners) { - try { - listener(enriched) - } catch (err) { - console.error('[agent-hooks] enriched status listener threw', err) - } - } - } - - private writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): void { - admitLegacyAgentStatus( + private writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): boolean { + return admitLegacyAgentStatus( this.state, 'main-status-update', entry, diff --git a/src/main/agent-hooks/server/server-structured-status-row.ts b/src/main/agent-hooks/server/server-structured-status-row.ts new file mode 100644 index 00000000000..93fda643409 --- /dev/null +++ b/src/main/agent-hooks/server/server-structured-status-row.ts @@ -0,0 +1,24 @@ +import { + pickParsedAgentStatusPayload, + type AgentStatusIpcPayload +} from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' + +/** Canonical rows supply legacy fanout without retaining a writable pane copy. */ +export function structuredStatusLegacyEvent( + row: AgentStatusIpcPayload +): EnrichedAgentHookEventPayload { + return { + paneKey: row.paneKey, + tabId: row.tabId, + worktreeId: row.worktreeId, + connectionId: row.connectionId, + receivedAt: row.receivedAt, + stateStartedAt: row.stateStartedAt, + evidenceObservedAt: row.evidenceObservedAt, + structuredHost: row.structuredHost, + ...(row.providerSession ? { providerSession: row.providerSession } : {}), + ...(row.observation ? { observation: row.observation } : {}), + payload: pickParsedAgentStatusPayload(row) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index d74a729dc2f..2ff6697eae9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -11,6 +11,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' import { AgentHookServer } from '../../agent-hooks/server' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' @@ -40,6 +42,51 @@ const IDENTITY: AgentSessionJournalIdentity = { providerHandle: { kind: 'codex', threadId: SESSION } } +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: IDENTITY.workspaceId, + workspaceKind: 'git-worktree' + }, + SESSION +) + +function ownerRecord(): AgentSessionRecord { + return { + schemaVersion: 2, + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: IDENTITY.workspaceId, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME', path: '/fixture/codex' }, + createdAt: 1, + updatedAt: 1, + lease: { + sessionId: SESSION, + runtimeKind: 'native', + runtimeFence: 1, + handoffStage: null, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + leaseDeadlineAt: 100, + lastRenewedAt: 1, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'fixture-key', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null + } + } +} + let root: string const journals = createTrackedJournalOpener() @@ -58,6 +105,7 @@ async function workingSession(): Promise<{ feed: StructuredAgentSessionStatusFeed sessions: Map journal: AgentSessionJournal + records: Map }> { const journal = await journals.open({ identity: IDENTITY, journalDir: join(root, SESSION) }) await journal.appendItem( @@ -75,28 +123,41 @@ async function workingSession(): Promise<{ SESSION, { journal, - params: { location: { workspaceId: IDENTITY.workspaceId }, provider: 'codex' }, + params: { + envelope: { + sessionId: SESSION, + clientOperationId: 'fixture-attach', + expectedRuntimeFence: 1, + payloadFingerprint: 'fixture-payload' + }, + location: ownerRecord().location, + provider: 'codex', + agent: 'codex', + accountHome: ownerRecord().accountHome, + runtimeKind: 'native' + }, fence: 1, hasProviderChild: true, acquisitionGeneration: null - } as unknown as StructuredAgentSessionHostSession + } ] ]) const server = new AgentHookServer() + const records = new Map([[SESSION, ownerRecord()]]) const feed = new StructuredAgentSessionStatusFeed({ sessions, - getRecord: () => null, + getRecord: (sessionId) => records.get(sessionId) ?? null, now: () => 1, statusSink: () => ({ - publish: (summary) => server.ingestStructuredStatus(summary), - forget: (sessionId) => server.dropStructuredStatus(sessionId) + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject) }) }) feed.publish(SESSION, journal) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ state: 'working', structuredHost: 'owned' }) ]) - return { server, feed, sessions, journal } + return { server, feed, sessions, journal, records } } function attachContext( @@ -133,6 +194,37 @@ const attachParams = { } as unknown as Parameters[2] describe('a session that leaves the host without an explicit close', () => { + it('forgets the retained exact subject after the record and live session are deleted first', async () => { + const { server, feed, sessions, records } = await workingSession() + const otherSubject = { ...SUBJECT, executionHostId: 'ssh:other-host' as const } + const original = server.getCanonicalStatusSnapshot().parents[0] + expect(original?.subject).toEqual(SUBJECT) + server.ingestStructuredStatus( + { + sessionId: SESSION, + workspaceId: IDENTITY.workspaceId, + agent: 'codex', + status: 'working', + latestPrompt: 'other host', + updatedAt: 10 + }, + otherSubject + ) + const drop = vi.spyOn(server, 'dropStructuredStatus') + const paneLookup = vi.spyOn(server, 'getStatusSnapshotForPane') + records.delete(SESSION) + sessions.delete(SESSION) + + feed.close(SESSION) + + expect(drop).toHaveBeenCalledExactlyOnceWith(SUBJECT) + expect(paneLookup).not.toHaveBeenCalled() + expect(server.getCanonicalStatusSnapshot().parents.map((row) => row.subject)).toEqual([ + otherSubject + ]) + expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ prompt: 'other host' })]) + }) + it('leaves the agent-status store with it when an attach fails', async () => { const { server, feed, sessions } = await workingSession() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts new file mode 100644 index 00000000000..29e450c187d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts @@ -0,0 +1,24 @@ +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export function indexedStatusFeedSession(session: { + journal: AgentSessionJournal + hasProviderChild?: boolean + fence?: number +}) { + return { + journal: session.journal, + fence: session.fence ?? 1, + ...(session.hasProviderChild !== undefined + ? { hasProviderChild: session.hasProviderChild } + : {}), + params: { + location: { + executionHostId: 'local' as const, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index b085cbd3012..e5d3d16e5dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -12,6 +12,7 @@ import { createClaudeJournalTranslator } from '../../claude/claude-structured-jo import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { indexedStatusFeedSession as indexed } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusFeedDeps, @@ -58,21 +59,6 @@ async function openJournal(sessionId = SESSION, now?: () => number) { }) } -function indexed(session: { - journal: Awaited> - hasProviderChild?: boolean - fence?: number -}) { - return { - journal: session.journal, - fence: session.fence ?? 1, - ...(session.hasProviderChild !== undefined - ? { hasProviderChild: session.hasProviderChild } - : {}), - params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } - } -} - function feedFor( sessions: Map< string, @@ -782,7 +768,7 @@ describe('StructuredAgentSessionStatusFeed', () => { describe('the status sink sees the roster the broadcast cache deliberately lacks', () => { function sinkFor() { const published: AgentSessionStatusSummary[] = [] - const forgotten: string[] = [] + const forgotten: Parameters[0][] = [] const sink: StructuredAgentSessionStatusSink = { publish: (summary) => published.push(summary), forget: (sessionId) => forgotten.push(sessionId) @@ -818,7 +804,16 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks // Exactly what `close` does after eviction: the cache keeps the projection, the sink does not. sessions.delete(SESSION) feed.forget(SESSION) - expect(forgotten).toEqual([SESSION]) + expect(forgotten).toEqual([ + { + kind: 'structured-session', + sessionId: SESSION, + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + } + ]) const late: AgentSessionStatusEvent[] = [] feed.subscribe({ id: 'list-2', emit: (event) => late.push(event) }) expect(late).toEqual([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 220a8116160..f4b97d015e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -23,6 +23,12 @@ import { import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' +import { + StructuredAgentSessionStatusOwnership, + type StructuredAgentSessionStatusSink +} from './structured-agent-session-status-ownership' + +export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' export type StructuredAgentSessionStatusSubscriber = { id: string @@ -31,19 +37,11 @@ export type StructuredAgentSessionStatusSubscriber = { type StatusFeedSession = { journal: AgentSessionJournal - params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } + params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } hasProviderChild?: boolean fence?: number } -/** Where the host's projections land for readers that see every agent alike (`worktree ps`, - * mobile, the hook store's own fanout). `forget` is the roster edge the broadcast cache - * deliberately never has. */ -export type StructuredAgentSessionStatusSink = { - publish: (summary: AgentSessionStatusSummary) => void - forget: (sessionId: string) => void -} - export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null @@ -108,6 +106,9 @@ export function createStructuredAgentSessionHostStatusFeed(args: { } export class StructuredAgentSessionStatusFeed { + private readonly ownership = new StructuredAgentSessionStatusOwnership(() => + this.deps.statusSink?.() + ) private readonly subscribers = new Map() private readonly published = new Map() // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. @@ -146,7 +147,7 @@ export class StructuredAgentSessionStatusFeed { /** The sink lists what is running; a forgotten session must not be in it. */ forget(sessionId: string): void { try { - this.deps.statusSink?.()?.forget(sessionId) + this.ownership.forget(sessionId) } catch (error) { console.warn('[structured-session-status] status sink forget failed', error) } @@ -173,11 +174,11 @@ export class StructuredAgentSessionStatusFeed { } const { hostExecutionOwned: _hostExecutionOwned, ...retained } = previous this.published.set(sessionId, retained) + this.sink(retained) this.broadcast({ type: 'status', session: retained }) - this.sink(retained) } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -192,8 +193,8 @@ export class StructuredAgentSessionStatusFeed { return } this.published.set(sessionId, summary) + this.sink(summary, session.params.location) this.broadcast({ type: 'status', session: summary }) - this.sink(summary) try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { @@ -262,9 +263,12 @@ export class StructuredAgentSessionStatusFeed { } /** A failing sink must never cost the subscribers their status event. */ - private sink(summary: AgentSessionStatusSummary): void { + private sink( + summary: AgentSessionStatusSummary, + location?: AgentSessionRecord['location'] + ): void { try { - this.deps.statusSink?.()?.publish(summary) + this.ownership.publish(summary, location) } catch (error) { console.warn('[structured-session-status] status sink publish failed', error) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts new file mode 100644 index 00000000000..b795b899567 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' +import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' + +const location: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-workspace', + workspaceKind: 'folder' +} +const summary: AgentSessionStatusSummary = { + sessionId: 'structured-session', + workspaceId: location.workspaceId, + agent: 'codex', + status: 'working', + latestPrompt: 'fixture', + updatedAt: 100 +} + +describe('structured status owner address retention', () => { + it('retains scope through record deletion and does not resurrect after forget', () => { + const sink = { publish: vi.fn(), forget: vi.fn() } + const owner = new StructuredAgentSessionStatusOwnership(() => sink) + const subject = makeStructuredAgentStatusSubject(location, summary.sessionId) + owner.publish(summary, location) + owner.publish({ ...summary, hostExecutionOwned: undefined }) + expect(sink.publish).toHaveBeenLastCalledWith(summary, subject) + owner.forget(summary.sessionId) + expect(sink.forget).toHaveBeenCalledExactlyOnceWith(subject) + owner.publish(summary) + owner.forget(summary.sessionId) + expect(sink.publish).toHaveBeenCalledTimes(2) + expect(sink.forget).toHaveBeenCalledOnce() + }) + + it('forgets the old exact scope before publishing a trusted location change', () => { + const sink = { publish: vi.fn(), forget: vi.fn() } + const owner = new StructuredAgentSessionStatusOwnership(() => sink) + owner.publish(summary, location) + const replacement = { ...location, executionHostId: 'ssh:second-host' as const } + owner.publish(summary, replacement) + expect(sink.forget).toHaveBeenCalledExactlyOnceWith( + makeStructuredAgentStatusSubject(location, summary.sessionId) + ) + expect(sink.forget.mock.invocationCallOrder[0]).toBeLessThan( + sink.publish.mock.invocationCallOrder[1] + ) + owner.forget(summary.sessionId) + expect(sink.forget).toHaveBeenLastCalledWith( + makeStructuredAgentStatusSubject(replacement, summary.sessionId) + ) + }) + + it('keeps the owner address when a downstream publication observer throws', () => { + const sink = { + publish: vi.fn(() => { + throw new Error('observer failed') + }), + forget: vi.fn() + } + const owner = new StructuredAgentSessionStatusOwnership(() => sink) + expect(() => owner.publish(summary, location)).toThrow('observer failed') + owner.forget(summary.sessionId) + expect(sink.forget).toHaveBeenCalledExactlyOnceWith( + makeStructuredAgentStatusSubject(location, summary.sessionId) + ) + }) + + it('does not fabricate location for an unknown session or an unavailable sink', () => { + const sink = { publish: vi.fn(), forget: vi.fn() } + const owner = new StructuredAgentSessionStatusOwnership(() => sink) + owner.publish(summary) + expect(sink.publish).not.toHaveBeenCalled() + const unavailable = new StructuredAgentSessionStatusOwnership(() => undefined) + expect(() => unavailable.publish(summary, location)).not.toThrow() + expect(() => unavailable.forget(summary.sessionId)).not.toThrow() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts new file mode 100644 index 00000000000..d9cadff4b4b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -0,0 +1,57 @@ +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { + parseAgentStatusSubject, + serializeAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../../shared/agent-status-subject' + +export type StructuredAgentSessionStatusSink = { + publish: ( + summary: AgentSessionStatusSummary, + subject: AgentStatusStructuredSessionSubject + ) => void + forget: (subject: AgentStatusStructuredSessionSubject) => void +} + +/** Retain the owner address because record removal may precede the final status callback. */ +export class StructuredAgentSessionStatusOwnership { + private readonly subjects = new Map() + + constructor(private readonly sink: () => StructuredAgentSessionStatusSink | undefined) {} + + publish(summary: AgentSessionStatusSummary, location?: AgentSessionExecutionLocation): void { + const sink = this.sink() + if (!sink || (!location && !this.subjects.has(summary.sessionId))) { + return + } + const subject = location + ? parseAgentStatusSubject({ + ...location, + kind: 'structured-session', + sessionId: summary.sessionId + }) + : this.subjects.get(summary.sessionId) + if (!subject || subject.kind !== 'structured-session') { + throw new Error('Structured status requires its full trusted execution location') + } + const previous = this.subjects.get(summary.sessionId) + if ( + previous && + serializeAgentStatusSubject(previous) !== serializeAgentStatusSubject(subject) + ) { + sink.forget(previous) + } + this.subjects.set(summary.sessionId, subject) + sink.publish(summary, subject) + } + + forget(sessionId: string): void { + const subject = this.subjects.get(sessionId) + if (!subject) { + return + } + this.sink()?.forget(subject) + this.subjects.delete(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 4f22b56397c..3827c5c1078 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -213,7 +213,18 @@ describe('AgentSessionSubscribers', () => { sessions: new Map([ [ SESSION, - { journal, params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' } } + { + journal, + params: { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex' + } + } ] ]), getRecord: () => null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 11b63781544..e26e65d1db4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -367,7 +367,14 @@ describe('a chat that closes', () => { claimStatus: 'released', ownerProcess: null }) - expect(statusSink.forget).toHaveBeenCalledWith(SESSION) + expect(statusSink.forget).toHaveBeenCalledWith({ + kind: 'structured-session', + sessionId: SESSION, + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) await expect(host.close(SESSION)).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(false) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 3dc84906c99..c736f4f4b4c 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -236,8 +236,8 @@ async function startOrcadRuntime( // read, so a row observed under one process otherwise acquires whatever process owns the pane now. readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), structuredAgentStatusSink: { - publish: (summary) => agentHookServer.ingestStructuredStatus(summary), - forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId) + publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), + forget: (subject) => agentHookServer.dropStructuredStatus(subject) }, reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts index e6db4b6af0a..22a7a790ae8 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts @@ -1,3 +1,4 @@ +import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' @@ -15,6 +16,15 @@ vi.mock('../../telemetry/cohort-classifier', () => ({ * green in typecheck while `orca worktree ps` and mobile's poll would list nothing. */ const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: TEST_WORKTREE_ID, + workspaceKind: 'git-worktree' + }, + SESSION +) beforeEach(() => { _internals.resetCachesForTests() @@ -23,15 +33,18 @@ beforeEach(() => { describe('worktree ps reads structured sessions from the agent-status store', () => { it('lists a host-held structured session with no terminal behind it', async () => { const statusStore = new AgentHookServer() - statusStore.ingestStructuredStatus({ - sessionId: SESSION, - workspaceId: TEST_WORKTREE_ID, - agent: 'claude', - status: 'working', - hostExecutionOwned: true, - latestPrompt: 'ship the thing', - updatedAt: 1_757_030_400_000 - }) + statusStore.ingestStructuredStatus( + { + sessionId: SESSION, + workspaceId: TEST_WORKTREE_ID, + agent: 'claude', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'ship the thing', + updatedAt: 1_757_030_400_000 + }, + SUBJECT + ) const getAgentStatusSnapshot = vi.fn(() => statusStore.getStatusSnapshot()) const { worktrees } = await new OrcaRuntimeService(store, undefined, { @@ -53,15 +66,18 @@ describe('worktree ps reads structured sessions from the agent-status store', () it('lists nothing once the host has dropped the session', async () => { const statusStore = new AgentHookServer() - statusStore.ingestStructuredStatus({ - sessionId: SESSION, - workspaceId: TEST_WORKTREE_ID, - agent: 'claude', - status: 'attention', - latestPrompt: 'rm the branch', - updatedAt: 1_757_030_400_000 - }) - statusStore.dropStructuredStatus(SESSION) + statusStore.ingestStructuredStatus( + { + sessionId: SESSION, + workspaceId: TEST_WORKTREE_ID, + agent: 'claude', + status: 'attention', + latestPrompt: 'rm the branch', + updatedAt: 1_757_030_400_000 + }, + SUBJECT + ) + statusStore.dropStructuredStatus(SUBJECT) const { worktrees } = await new OrcaRuntimeService(store, undefined, { getAgentStatusSnapshot: () => statusStore.getStatusSnapshot() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index b8fe9d5b01b..aad992d4926 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -104,7 +104,15 @@ function statusFeed(): StructuredAgentSessionStatusFeed { lastActivityAt: () => 2, snapshot: () => ({ items: STATUS_ITEMS }) } as unknown as AgentSessionJournal, - params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } + params: { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const + } } ] ]), diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 5b517f1bfc8..01c72619830 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -1,3 +1,4 @@ +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { beforeEach, describe, expect, it, vi } from 'vitest' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' @@ -21,6 +22,15 @@ vi.mock('../telemetry/cohort-classifier', () => ({ */ const WORKTREE_ID = 'repo-1::/workspace/app' const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' + }, + SESSION +) function summary(over: Partial = {}): AgentSessionStatusSummary { return { @@ -38,7 +48,7 @@ function summary(over: Partial = {}): AgentSessionSta function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummary { const store = new AgentHookServer() for (const entry of summaries) { - store.ingestStructuredStatus(entry) + store.ingestStructuredStatus(entry, SUBJECT) } const row = { worktreeId: WORKTREE_ID, diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index afe5a3a2a71..b1e43a9025d 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -1,3 +1,4 @@ +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -26,6 +27,15 @@ vi.mock('../telemetry/cohort-classifier', () => ({ */ const WORKTREE_ID = 'repo-1::/workspace/app' const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' + }, + SESSION +) const IDENTITY = { provider: 'codex', threadId: 'thread-1', @@ -80,7 +90,15 @@ async function awaitingApproval() { { journal, hasProviderChild: true, - params: { location: { workspaceId: WORKTREE_ID }, provider: 'codex' as const } + params: { + location: { + executionHostId: 'local' as const, + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const + } } ] ]) @@ -91,9 +109,9 @@ async function awaitingApproval() { getRecord: () => null, now: () => Date.now(), statusSink: () => ({ - publish: (summary) => { + publish: (summary, subject) => { published.push(summary) - store.ingestStructuredStatus(summary) + store.ingestStructuredStatus(summary, subject) }, forget: (sessionId) => store.dropStructuredStatus(sessionId) }) @@ -156,7 +174,7 @@ describe('worktree ps and a closed structured chat', () => { updatedAt: Date.now() - 30 * 60 * 1000 - 1, status: 'working' as const } - store.ingestStructuredStatus(aged) + store.ingestStructuredStatus(aged, SUBJECT) const row = worktreeFor(store) expect(row.agents).toHaveLength(1) expect(row.agents[0]?.state).toBe('working') @@ -171,7 +189,7 @@ describe('worktree ps and a closed structured chat', () => { hostExecutionOwned: true as const, updatedAt: Date.now() - 30 * 60 * 1000 - 1 } - store.ingestStructuredStatus(aged) + store.ingestStructuredStatus(aged, SUBJECT) const row = worktreeFor(store) expect(row.agents).toHaveLength(1) expect(row.agents[0]?.state).toBe('blocked') @@ -182,7 +200,7 @@ describe('worktree ps and a closed structured chat', () => { it('lets an aged approval decay once the host no longer owns the child', async () => { const { store, published } = await awaitingApproval() const { hostExecutionOwned: _owned, ...held } = published.at(-1)! - store.ingestStructuredStatus({ ...held, updatedAt: Date.now() - 30 * 60 * 1000 - 1 }) + store.ingestStructuredStatus({ ...held, updatedAt: Date.now() - 30 * 60 * 1000 - 1 }, SUBJECT) const row = worktreeFor(store) expect(row.agents).toHaveLength(1) expect(row.agents[0]?.state).toBe('blocked') diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 2a66536291a..670859444c9 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -149,6 +149,15 @@ describe('structured worker identity', () => { expect(parsed!.tabId).toBe(`structured-agent-session-${SESSION_ID}`) }) + it('rejects a public status pane even though its leaf is a valid terminal UUID', () => { + const paneKey = structuredAgentSessionPaneKey( + `structured-agent-session-${SESSION_ID}`, + SESSION_ID + ) + expect(isTerminalLeafId(parsePaneKey(paneKey)!.leafId)).toBe(true) + expect(structuredWorkerPaneKeyBelongsToSession(paneKey, SESSION_ID)).toBe(false) + }) + it('round-trips the session id through the process incarnation', () => { const incarnation = structuredWorkerProcessIncarnation(SESSION_ID) expect(sessionIdFromStructuredWorkerIncarnation(incarnation)).toBe(SESSION_ID) @@ -232,6 +241,24 @@ describe('structured worker identity registry', () => { ).toBeNull() }) + it('cannot rehydrate a worker credential from a public status subject', () => { + const handle = mintStructuredWorkerHandle() + expect( + registry.rehydrate({ + terminal_handle: handle, + pane_key: structuredAgentSessionPaneKey( + `structured-agent-session-${SESSION_ID}`, + SESSION_ID + ), + process_incarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktree_id: 'wt_1', + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) + ).toBeNull() + expect(registry.get(handle)).toBeNull() + expect(registry.getBySessionId(SESSION_ID)).toBeNull() + }) + it('forgets both indexes', () => { const handle = mintStructuredWorkerHandle() registry.register({ diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index b29d68c297a..d89ed88c282 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -96,6 +96,7 @@ function persistedStructuredWorkerPaneKeyIsValid( paneKey !== structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) && parsed && parsed.tabId === structuredAgentSessionTabId(sessionId) && + paneKey !== structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) && isTerminalLeafId(parsed.leafId) ) } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index a0a65d54e7a..6d7ba1fe23a 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -92,8 +92,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Why: structured chats have no hooks, so the host writes their projections here itself; the // snapshot above then lists them for the CLI and mobile without a second store. structuredAgentStatusSink: { - publish: (summary) => agentHookServer.ingestStructuredStatus(summary), - forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId) + publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), + forget: (subject) => agentHookServer.dropStructuredStatus(subject) }, // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every // read, so a row observed under one process otherwise acquires whatever the pane owns now. diff --git a/src/relay/agent-status-store-relay-context.test.ts b/src/relay/agent-status-store-relay-context.test.ts index 92319a2e644..853722f3cf0 100644 --- a/src/relay/agent-status-store-relay-context.test.ts +++ b/src/relay/agent-status-store-relay-context.test.ts @@ -10,10 +10,14 @@ const SHARED_CORE_FILES = [ 'agent-status-child-work-admission.ts', 'agent-status-child-work-admission-core.ts', 'agent-status-child-work-admission-operations.ts', + 'agent-status-child-work-resume.ts', 'agent-status-child-work-alias.ts', + 'agent-status-child-work-binding.ts', 'agent-status-child-work-freshness.ts', 'agent-status-child-work-projection.ts', 'agent-status-store.ts', + 'agent-status-store-byte-budget.ts', + 'agent-status-store-child-queries.ts', 'agent-status-store-codec.ts', 'agent-status-store-mutation.ts', 'agent-status-store-contract.ts', diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index b82f2418514..f2c77fb5c98 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -320,18 +320,20 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()).toEqual([expect.objectContaining({ subagents: undefined })]) }) - it('keeps quiet live children authoritative and reconfirms them per session after reconnect', async () => { + it('requires fresh parent evidence as well as a reconfirmed feed after reconnect', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) const live = summary({ backgroundTasks: [{ id: 'child', kind: 'agent', state: 'working' }] }) + let parentIsFresh = false const childState = () => buildSubagentChildRows({ parentEntry: statuses()[0], tab: structuredTab as never, - parentIsFresh: false + parentIsFresh })[0]?.state act(() => feed().emit({ type: 'snapshot', sessions: [live] })) - // A hook's evidence window has expired, but the host has not retracted its live task. + expect(childState()).toBe('unverifiable') + parentIsFresh = true expect(childState()).toBe('working') act(() => feed().emit({ type: 'end' })) expect(childState()).toBe('unverifiable') @@ -340,6 +342,8 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(childState()).toBe('unverifiable') act(() => feed(1).emit({ type: 'status', session: live })) expect(childState()).toBe('working') + parentIsFresh = false + expect(childState()).toBe('unverifiable') const writes = mocks.setAgentStatus.mock.calls.length act(() => feed(1).emit({ type: 'status', session: live })) expect(mocks.setAgentStatus).toHaveBeenCalledTimes(writes) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 34393219518..b02afa1fbfc 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -768,7 +768,7 @@ describe('applyAgentRowLineage', () => { expect(ordered[2].lineage).toMatchObject({ depth: 1, isLastSibling: true }) }) - it('decays working subagent child rows to idle when the parent status is stale', () => { + it('marks working subagent child rows unverifiable when the parent status is stale', () => { const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', subagents: [{ id: 'a1', state: 'working', startedAt: 1000 }] @@ -781,7 +781,7 @@ describe('applyAgentRowLineage', () => { }) const child = rows.find((row) => row.rowSource === 'subagent') - expect(child?.state).toBe('idle') + expect(child?.state).toBe('unverifiable') }) it('surfaces a live subagent waiting state', () => { diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts new file mode 100644 index 00000000000..c535e3e80ee --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { buildSubagentChildRows } from './worktree-subagent-child-rows' + +const tab: TerminalTab = { + id: 'parent-tab', + ptyId: null, + worktreeId: 'folder-workspace', + title: 'Parent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +describe('shared CLI and structured child freshness', () => { + it.each([ + ['working', true, undefined, 'working'], + ['working', true, 'live', 'working'], + ['working', false, undefined, 'unverifiable'], + ['working', false, 'live', 'unverifiable'], + ['working', true, 'unverifiable', 'unverifiable'], + ['working', false, 'unverifiable', 'unverifiable'], + ['waiting', false, undefined, 'unverifiable'], + ['waiting', false, 'live', 'unverifiable'], + ['blocked', false, undefined, 'unverifiable'], + ['blocked', false, 'live', 'unverifiable'], + ['idle', false, undefined, 'idle'], + ['idle', false, 'live', 'idle'], + ['idle', false, 'unverifiable', 'idle'], + ['unverifiable', true, 'live', 'unverifiable'] + ] as const)( + '%s with fresh parent %s and transport %s projects %s', + (state, parentIsFresh, subagentObservation, expected) => { + const parentEntry: AgentStatusEntry = { + paneKey: 'parent-pane', + tabId: tab.id, + worktreeId: tab.worktreeId, + state: 'working', + prompt: 'parent prompt', + updatedAt: 100, + stateStartedAt: 10, + stateHistory: [], + subagentObservation, + subagents: [{ id: 'child', state, startedAt: 20 }] + } + const row = buildSubagentChildRows({ parentEntry, tab, parentIsFresh })[0] + expect(row.state).toBe(expected) + expect(row.activationPaneKey).toBe(parentEntry.paneKey) + expect(row.startedAt).toBe(20) + expect(parentEntry.subagents).toEqual([{ id: 'child', state, startedAt: 20 }]) + } + ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts index a140fc30e85..e629a8e352b 100644 --- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts @@ -1,5 +1,6 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { resolveAgentChildWorkFreshness } from '../../../../shared/agent-status-child-work-freshness' import type { TerminalTab } from '../../../../shared/terminal-tab-types' /** Row-identity key for an in-process subagent child row. The NUL separator @@ -21,7 +22,7 @@ export function buildSubagentChildRows(args: { parentEntry: AgentStatusEntry tab: TerminalTab /** Freshness of the parent's hook stream. A stale parent means active child - * states are equally stale, so they decay to idle together. */ + * states are equally unverifiable. */ parentIsFresh: boolean }): DashboardAgentRow[] { const subagents = args.parentEntry.subagents @@ -29,17 +30,14 @@ export function buildSubagentChildRows(args: { return [] } return subagents.map((subagent) => { - const observation = args.parentEntry.subagentObservation - const fresh = observation === 'live' || (observation === undefined && args.parentIsFresh) - const activeState = - fresh && subagent.state !== 'idle' && subagent.state !== 'unverifiable' - ? subagent.state - : undefined - const state = - subagent.state === 'unverifiable' || - (observation === 'unverifiable' && subagent.state !== 'idle') - ? 'unverifiable' - : (activeState ?? 'idle') + const freshness = resolveAgentChildWorkFreshness({ + state: subagent.state, + membership: 'live', + parentEvidenceFresh: args.parentIsFresh, + transportObservation: args.parentEntry.subagentObservation ?? 'live' + }) + const state = freshness === 'done' ? 'idle' : freshness === 'monitoring' ? 'working' : freshness + const activeState = state !== 'idle' && state !== 'unverifiable' ? state : undefined const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id) const entry: AgentStatusEntry = { diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 0f40b433801..d4daeaa04b2 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -119,6 +119,15 @@ export function admitLegacyAgentStatus( return legacyStatusAdapter(state).admit(caller, mode, entry, options) } +export function canAdmitLegacyAgentStatusEntry( + state: HookListenerState, + caller: AgentStatusLegacyIngressCaller, + entry: AgentHookEventPayload, + mode: AgentStatusLegacyAdmissionMode +): boolean { + return legacyStatusAdapter(state).canAdmit(caller, mode, entry) +} + export function deleteLegacyAgentStatus(state: HookListenerState, paneKey: string): boolean { return legacyStatusAdapter(state).delete(paneKey) } diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index 7c797f34d4b..bc8b9e4fe34 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -130,6 +130,14 @@ export function updateExistingAgentChildWork( aliases: AgentChildWorkAliasInput[], removeAliases: string[] = [] ): AgentChildWorkAdmissionResult { + if ( + child.membership === 'settled' && + (request.membership !== 'settled' || + request.state !== child.state || + request.outcome !== child.outcome) + ) { + return rejectAgentChildWorkAdmission('stale-invocation') + } const updated = buildAgentChildWork( request, child.childWorkId, @@ -146,10 +154,7 @@ export function resolveAgentChildWorkAliasRecords( store: AgentStatusStore, aliases: AgentChildWorkAliasInput[] ): AgentChildWorkAliasRecord[] { - return aliases.flatMap((alias) => { - const found = store.getAlias(alias) - return found ? [found] : [] - }) + return store.resolveChildAliases(aliases) } export function validateExistingAgentChildWork( diff --git a/src/shared/agent-status-child-work-admission-operations.ts b/src/shared/agent-status-child-work-admission-operations.ts index 57b35736df4..2fb86e49c2e 100644 --- a/src/shared/agent-status-child-work-admission-operations.ts +++ b/src/shared/agent-status-child-work-admission-operations.ts @@ -1,9 +1,5 @@ -import { serializeAgentChildWorkAliasKey } from './agent-status-child-work-alias' -import { - AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX, - agentChildWorkFencesEqual, - type AgentChildWorkId -} from './agent-status-child-work' +import { serializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' +import { agentChildWorkFencesEqual, type AgentChildWorkId } from './agent-status-child-work' import { agentChildWorkAliasesForChild, buildAgentChildWork, @@ -19,8 +15,7 @@ import type { AgentChildWorkAdmissionResult, AgentChildWorkAdoptRequest, AgentChildWorkAnnounceRequest, - AgentChildWorkReparentRequest, - AgentChildWorkResumeRequest + AgentChildWorkReparentRequest } from './agent-status-child-work-admission' import { parseAgentChildWorkInput, @@ -74,13 +69,18 @@ export function announceAgentChildWork( ? commitAgentChildWork(store, child, aliases, true) : rejectAgentChildWorkAdmission('invalid') } - if (bindings.length !== exact.length || exactIds.size > 1) { + if ((exact.length === 0 && bindings.length > 0) || exactIds.size > 1) { return rejectAgentChildWorkAdmission(exactIds.size > 1 ? 'ambiguous' : 'stale-invocation') } const existingId = exact[0]?.childWorkId if (existingId) { const child = findAgentChildWork(store, existingId) - if (!child) { + if ( + !child || + !agentStatusSubjectsEqual(child.parent, parent) || + child.provider !== request.provider || + child.kind !== request.kind + ) { return rejectAgentChildWorkAdmission('ambiguous') } if (!agentChildWorkFencesEqual(child.invocation, fence)) { @@ -150,7 +150,7 @@ export function adoptAgentChildWork( const oldAliases = agentChildWorkAliasesForChild(store, child.childWorkId) const removeAliases = oldAliases .filter((alias) => alias.kind !== request.kind) - .map(serializeAgentChildWorkAliasKey) + .map(serializeAgentChildWorkBindingKey) const reclassified = oldAliases.map((alias) => ({ parent: alias.parent, provider: alias.provider, @@ -162,7 +162,7 @@ export function adoptAgentChildWork( fence: alias.fence })) const unique = new Map( - [...reclassified, ...aliases].map((alias) => [serializeAgentChildWorkAliasKey(alias), alias]) + [...reclassified, ...aliases].map((alias) => [serializeAgentChildWorkBindingKey(alias), alias]) ) const reclassifiedCollisions = resolveAgentChildWorkAliasRecords(store, [ ...unique.values() @@ -173,72 +173,6 @@ export function adoptAgentChildWork( return updateExistingAgentChildWork(store, request, child, [...unique.values()], removeAliases) } -export function resumeAgentChildWork( - store: AgentStatusStore, - request: AgentChildWorkResumeRequest -): AgentChildWorkAdmissionResult { - const child = findAgentChildWork(store, request.childWorkId) - const invalid = validateExistingAgentChildWork( - child, - request.parent, - request.provider, - request.expectedFence - ) - const nextFence = parseAgentChildWorkInvocationFence(request.nextFence) - if (invalid || !child) { - return invalid ?? rejectAgentChildWorkAdmission('unknown-child') - } - if (!nextFence) { - return rejectAgentChildWorkAdmission('invalid') - } - if (nextFence.generation <= child.invocation.generation) { - return rejectAgentChildWorkAdmission('stale-invocation') - } - const aliases = buildAgentChildWorkAliases( - request.parent, - request.provider, - request.kind, - request.aliases, - child.childWorkId, - nextFence - ) - if (!aliases) { - return rejectAgentChildWorkAdmission('invalid') - } - const collisions = resolveAgentChildWorkAliasRecords(store, aliases).filter( - (binding) => binding.childWorkId !== child.childWorkId - ) - if (collisions.length > 0) { - return rejectAgentChildWorkAdmission('ambiguous') - } - const previousInvocations = [ - ...(child.previousInvocations ?? []), - { - fence: child.invocation, - ...(child.outcome !== undefined ? { outcome: child.outcome } : {}), - ...(child.membership === 'settled' ? { settledAt: child.observedAt } : {}) - } - ].slice(-AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX) - const retainedFences = [nextFence, ...previousInvocations.map((entry) => entry.fence)] - const nextAliasKeys = new Set(aliases.map(serializeAgentChildWorkAliasKey)) - const removeAliases = agentChildWorkAliasesForChild(store, child.childWorkId) - .filter( - (alias) => !retainedFences.some((fence) => agentChildWorkFencesEqual(alias.fence, fence)) - ) - .map(serializeAgentChildWorkAliasKey) - .filter((key) => !nextAliasKeys.has(key)) - const resumed = buildAgentChildWork( - request, - child.childWorkId, - child.firstObservedAt, - nextFence, - previousInvocations - ) - return resumed - ? commitAgentChildWork(store, resumed, aliases, false, removeAliases) - : rejectAgentChildWorkAdmission('invalid') -} - export function reparentAgentChildWork( store: AgentStatusStore, request: AgentChildWorkReparentRequest @@ -282,7 +216,7 @@ export function reparentAgentChildWork( moved, aliases, false, - oldAliases.map(serializeAgentChildWorkAliasKey) + oldAliases.map(serializeAgentChildWorkBindingKey) ) : rejectAgentChildWorkAdmission('invalid') } diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index 554191db895..493fba612e2 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -13,9 +13,9 @@ import type { import { adoptAgentChildWork, announceAgentChildWork, - reparentAgentChildWork, - resumeAgentChildWork + reparentAgentChildWork } from './agent-status-child-work-admission-operations' +import { resumeAgentChildWork } from './agent-status-child-work-resume' import { authorizeAgentChildWorkStop } from './agent-status-child-work-stop' import type { AgentStatusStore } from './agent-status-store' import type { AgentStatusSubject } from './agent-status-subject' diff --git a/src/shared/agent-status-child-work-binding.ts b/src/shared/agent-status-child-work-binding.ts new file mode 100644 index 00000000000..c48aee4b4c7 --- /dev/null +++ b/src/shared/agent-status-child-work-binding.ts @@ -0,0 +1,56 @@ +import { + deserializeAgentChildWorkAliasKey, + parseAgentChildWorkAliasInput, + serializeAgentChildWorkAliasKey, + type AgentChildWorkAliasInput +} from './agent-status-child-work-alias' + +const BINDING_PREFIX = 'agent-child-work-binding-v1:' + +/** One alias may name several proven lifetimes; the binding, not the alias, is a row key. */ +export function serializeAgentChildWorkBindingKey(binding: AgentChildWorkAliasInput): string { + const { parent, provider, segmentId, kind, aliasKind, alias, childWorkId, fence } = binding + const parsed = parseAgentChildWorkAliasInput({ + parent, + provider, + segmentId, + kind, + aliasKind, + alias, + childWorkId, + fence + }) + if (!parsed) { + throw new Error('Invalid child-work binding') + } + return `${BINDING_PREFIX}${JSON.stringify([ + serializeAgentChildWorkAliasKey(parsed), + parsed.childWorkId, + parsed.fence.invocationId, + parsed.fence.generation + ])}` +} + +export function deserializeAgentChildWorkBindingKey( + value: string +): AgentChildWorkAliasInput | null { + if (!value.startsWith(BINDING_PREFIX)) { + return null + } + let tuple: unknown + try { + tuple = JSON.parse(value.slice(BINDING_PREFIX.length)) + } catch { + return null + } + if (!Array.isArray(tuple) || tuple.length !== 4 || typeof tuple[0] !== 'string') { + return null + } + const alias = deserializeAgentChildWorkAliasKey(tuple[0]) + const parsed = parseAgentChildWorkAliasInput({ + ...alias, + childWorkId: tuple[1], + fence: { invocationId: tuple[2], generation: tuple[3] } + }) + return parsed && serializeAgentChildWorkBindingKey(parsed) === value ? parsed : null +} diff --git a/src/shared/agent-status-child-work-freshness.ts b/src/shared/agent-status-child-work-freshness.ts index ca513c81143..0bd32a87a11 100644 --- a/src/shared/agent-status-child-work-freshness.ts +++ b/src/shared/agent-status-child-work-freshness.ts @@ -11,7 +11,7 @@ export type AgentChildWorkFreshnessInput = { export function resolveAgentChildWorkFreshness( input: AgentChildWorkFreshnessInput ): AgentChildWorkState { - if (input.membership === 'settled') { + if (input.membership === 'settled' || input.state === 'idle' || input.state === 'done') { return input.state } return input.parentEvidenceFresh && input.transportObservation === 'live' diff --git a/src/shared/agent-status-child-work-lifetime.test.ts b/src/shared/agent-status-child-work-lifetime.test.ts new file mode 100644 index 00000000000..29242eb1862 --- /dev/null +++ b/src/shared/agent-status-child-work-lifetime.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from 'vitest' +import { AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX } from './agent-status-child-work' +import { + createAgentChildWorkAdmission, + type AgentChildWorkAnnounceRequest +} from './agent-status-child-work-admission' +import { serializeAgentChildWorkAliasKey } from './agent-status-child-work-alias' +import { createAgentStatusStore } from './agent-status-store' +import { + deserializeAgentStatusStoreSnapshot, + serializeAgentStatusStoreSnapshot +} from './agent-status-store-persistence' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'ssh:host-a', + wslDistro: null, + workspaceId: 'folder-a', + workspaceKind: 'folder' + }, + 'session_11111111-1111-4111-8111-111111111111' +) + +function observation( + overrides: Partial = {} +): AgentChildWorkAnnounceRequest { + return { + parent, + provider: 'claude', + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], + fence: { invocationId: 'invocation-1', generation: 1 }, + lifetime: 'current', + kind: 'agent', + state: 'working', + membership: 'live', + observedAt: 10, + stoppable: true, + provenance: { source: 'structured-session', producerId: 'journal-1' }, + ...overrides + } +} + +function setup() { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let sequence = 0 + const mintChildWorkId = vi.fn(() => `child-${++sequence}`) + return { + store, + mintChildWorkId, + admission: createAgentChildWorkAdmission(store, { mintChildWorkId }) + } +} + +describe('child-work lifetime fencing', () => { + it('keeps set-valued alias bindings across proven reuse and persistence', () => { + const { store, admission } = setup() + expect(admission.announce(observation())).toMatchObject({ + accepted: true, + childWorkId: 'child-1' + }) + expect( + admission.announce( + observation({ + lifetime: 'proven-new', + fence: { invocationId: 'invocation-2', generation: 2 }, + observedAt: 20 + }) + ) + ).toMatchObject({ accepted: true, childWorkId: 'child-2' }) + const snapshot = store.getSnapshot() + expect(snapshot.aliases).toHaveLength(2) + expect(new Set(snapshot.aliases.map(serializeAgentChildWorkAliasKey)).size).toBe(1) + const restored = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' }) + expect( + restored.applySnapshot( + deserializeAgentStatusStoreSnapshot(serializeAgentStatusStoreSnapshot(snapshot)) + ) + ).toBe(true) + expect(restored.getSnapshot().aliases).toEqual(snapshot.aliases) + expect(restored.getChildren(parent)).toEqual(snapshot.children) + }) + + it('rejects old alias updates and stops after resume without cloning a snapshot for admission', () => { + const { store, admission } = setup() + const snapshot = vi.spyOn(store, 'getSnapshot') + admission.announce(observation()) + expect( + admission.resume({ + ...observation({ observedAt: 20 }), + childWorkId: 'child-1', + expectedFence: observation().fence, + nextFence: { invocationId: 'invocation-2', generation: 2 } + }) + ).toMatchObject({ accepted: true }) + const before = store.getChild('child-1') + expect( + admission.announce( + observation({ observedAt: 30, state: 'done', membership: 'settled', outcome: 'failed' }) + ) + ).toEqual({ accepted: false, reason: 'stale-invocation' }) + expect( + admission.authorizeStop({ + parent, + childWorkId: 'child-1', + expectedFence: observation().fence + }) + ).toBeNull() + expect(store.getChild('child-1')).toEqual(before) + expect(snapshot).not.toHaveBeenCalled() + }) + + it('does not reactivate settled history via an ordinary re-announcement', () => { + const { store, admission } = setup() + admission.announce(observation({ state: 'done', membership: 'settled', outcome: 'cancelled' })) + expect(admission.announce(observation({ observedAt: 20 }))).toEqual({ + accepted: false, + reason: 'stale-invocation' + }) + expect(store.getChild('child-1')).toMatchObject({ membership: 'settled', outcome: 'cancelled' }) + }) + + it('does not remint a deleted child from a delayed observation after restart', () => { + const { store, admission } = setup() + admission.announce(observation()) + expect(store.applyMutation({ removeChildren: ['child-1'] })).not.toBeNull() + const restored = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' }) + expect(restored.applySnapshot(store.getSnapshot())).toBe(true) + const mintChildWorkId = vi.fn(() => 'replacement') + const restarted = createAgentChildWorkAdmission(restored, { mintChildWorkId }) + expect(restarted.announce(observation({ observedAt: 30 })).accepted).toBe(false) + expect( + restarted.announce(observation({ lifetime: 'proven-new', observedAt: 30 })).accepted + ).toBe(false) + expect(mintChildWorkId).not.toHaveBeenCalled() + expect(restored.getChildren(parent)).toEqual([]) + }) + + it('retains retired-fence rejection beyond bounded invocation history', () => { + const { store, admission } = setup() + admission.announce(observation()) + for ( + let generation = 2; + generation <= AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX + 3; + generation++ + ) { + expect( + admission.resume({ + ...observation({ observedAt: generation * 10 }), + childWorkId: 'child-1', + expectedFence: { + invocationId: `invocation-${generation - 1}`, + generation: generation - 1 + }, + nextFence: { invocationId: `invocation-${generation}`, generation } + }) + ).toMatchObject({ accepted: true }) + } + expect(store.getChild('child-1')?.previousInvocations).toHaveLength( + AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX + ) + expect(store.getSnapshot().aliases).toHaveLength(AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX + 1) + expect(admission.announce(observation({ observedAt: 1000 })).accepted).toBe(false) + expect( + admission.announce(observation({ observedAt: 1000, lifetime: 'proven-new' })).accepted + ).toBe(false) + expect( + admission.resume({ + ...observation({ observedAt: 1000 }), + childWorkId: 'child-1', + expectedFence: { invocationId: 'invocation-35', generation: 35 }, + nextFence: observation().fence + }).accepted + ).toBe(false) + }) + + it('fences former parent and provisional-kind aliases after explicit moves', () => { + const { store, admission, mintChildWorkId } = setup() + admission.announce(observation({ kind: 'unknown' })) + expect( + admission.adopt({ + ...observation({ observedAt: 20 }), + childWorkId: 'child-1', + expectedFence: observation().fence + }) + ).toMatchObject({ accepted: true }) + expect(admission.announce(observation({ kind: 'unknown', observedAt: 30 })).accepted).toBe( + false + ) + const toParent = { ...parent, workspaceId: 'folder-b' } + expect(store.applyMutation({ parent: { subject: toParent } })).not.toBeNull() + expect( + admission.reparent({ + childWorkId: 'child-1', + fromParent: parent, + toParent, + expectedFence: observation().fence, + observedAt: 40 + }) + ).toMatchObject({ accepted: true }) + expect(admission.announce(observation({ observedAt: 50 })).accepted).toBe(false) + expect(mintChildWorkId).toHaveBeenCalledTimes(1) + expect(store.getChild('child-1')?.parent).toEqual(toParent) + }) +}) diff --git a/src/shared/agent-status-child-work-projection.test.ts b/src/shared/agent-status-child-work-projection.test.ts index 91200735498..add9d8a14ca 100644 --- a/src/shared/agent-status-child-work-projection.test.ts +++ b/src/shared/agent-status-child-work-projection.test.ts @@ -145,7 +145,7 @@ describe('resolveAgentChildWorkFreshness', () => { } ) - it('does not turn live idle into settled or rewrite settled history on contact loss', () => { + it('preserves idle evidence and settled history on contact loss', () => { expect( resolveAgentChildWorkFreshness({ state: 'idle', @@ -153,7 +153,7 @@ describe('resolveAgentChildWorkFreshness', () => { parentEvidenceFresh: false, transportObservation: 'live' }) - ).toBe('unverifiable') + ).toBe('idle') expect( resolveAgentChildWorkFreshness({ state: 'done', diff --git a/src/shared/agent-status-child-work-resume.ts b/src/shared/agent-status-child-work-resume.ts new file mode 100644 index 00000000000..b8b87efccf5 --- /dev/null +++ b/src/shared/agent-status-child-work-resume.ts @@ -0,0 +1,85 @@ +import { serializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' +import { + AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX, + agentChildWorkFencesEqual +} from './agent-status-child-work' +import { + agentChildWorkAliasesForChild, + buildAgentChildWork, + buildAgentChildWorkAliases, + commitAgentChildWork, + findAgentChildWork, + rejectAgentChildWorkAdmission, + resolveAgentChildWorkAliasRecords, + validateExistingAgentChildWork +} from './agent-status-child-work-admission-core' +import type { + AgentChildWorkAdmissionResult, + AgentChildWorkResumeRequest +} from './agent-status-child-work-admission' +import { parseAgentChildWorkInvocationFence } from './agent-status-child-work-codec' +import type { AgentStatusStore } from './agent-status-store' + +export function resumeAgentChildWork( + store: AgentStatusStore, + request: AgentChildWorkResumeRequest +): AgentChildWorkAdmissionResult { + const child = findAgentChildWork(store, request.childWorkId) + const invalid = validateExistingAgentChildWork( + child, + request.parent, + request.provider, + request.expectedFence + ) + const nextFence = parseAgentChildWorkInvocationFence(request.nextFence) + if (invalid || !child) { + return invalid ?? rejectAgentChildWorkAdmission('unknown-child') + } + if (!nextFence) { + return rejectAgentChildWorkAdmission('invalid') + } + if (nextFence.generation <= child.invocation.generation) { + return rejectAgentChildWorkAdmission('stale-invocation') + } + const aliases = buildAgentChildWorkAliases( + request.parent, + request.provider, + request.kind, + request.aliases, + child.childWorkId, + nextFence + ) + if (!aliases) { + return rejectAgentChildWorkAdmission('invalid') + } + const collisions = resolveAgentChildWorkAliasRecords(store, aliases).filter( + (binding) => binding.childWorkId !== child.childWorkId + ) + if (collisions.length > 0) { + return rejectAgentChildWorkAdmission('ambiguous') + } + const previousInvocations = [ + ...(child.previousInvocations ?? []), + { + fence: child.invocation, + ...(child.outcome !== undefined ? { outcome: child.outcome } : {}), + ...(child.membership === 'settled' ? { settledAt: child.observedAt } : {}) + } + ].slice(-AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX) + const resumed = buildAgentChildWork( + request, + child.childWorkId, + child.firstObservedAt, + nextFence, + previousInvocations + ) + const retainedFences = [nextFence, ...previousInvocations.map((entry) => entry.fence)] + const removeAliases = agentChildWorkAliasesForChild(store, child.childWorkId) + .filter( + (alias) => !retainedFences.some((fence) => agentChildWorkFencesEqual(fence, alias.fence)) + ) + .map(serializeAgentChildWorkBindingKey) + return resumed + ? commitAgentChildWork(store, resumed, aliases, false, removeAliases) + : rejectAgentChildWorkAdmission('invalid') +} diff --git a/src/shared/agent-status-legacy-adapter.test.ts b/src/shared/agent-status-legacy-adapter.test.ts index ff64e5ac9ca..484084e01d6 100644 --- a/src/shared/agent-status-legacy-adapter.test.ts +++ b/src/shared/agent-status-legacy-adapter.test.ts @@ -28,11 +28,18 @@ describe('legacy agent-status adapter', () => { expect(adapter.view.get(entry.paneKey)).toBe(entry) }) - it('refuses keys already owned by the canonical projection', () => { + it('refuses structured rows and keys already owned by the canonical projection', () => { const canonicalPaneKeys = new Set() const adapter = createAgentStatusLegacyAdapter({ isCanonicalPaneKey: (paneKey) => canonicalPaneKeys.has(paneKey) }) + const structured = { ...status('structured-pane'), structuredHost: 'owned' as const } + + expect( + adapter.admit('main-status-update', AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, structured) + ).toBe(false) + expect(adapter.view.size).toBe(0) + const prior = status('canonical-pane', 'legacy before canonical publication') expect(adapter.admit('main-status-update', AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, prior)).toBe( true @@ -137,21 +144,27 @@ describe('legacy agent-status adapter', () => { expect(adapter.view.get('immutable')?.payload.prompt).toBe('work') }) - it('assigns listing order once per live row and preserves it across refresh and move', () => { + it('preserves Map insertion order across refresh, explicit reorder and relocation', () => { let nextOrder = 40 const adapter = createAgentStatusLegacyAdapter({ nextListingOrder: () => nextOrder++ }) adapter.admit('main-status-update', AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, status('pane')) expect(adapter.listingOrder('pane')).toBe(40) + adapter.admit( + 'main-status-update', + AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, + status('pane', 'ordinary refresh') + ) + expect(adapter.listingOrder('pane')).toBe(40) adapter.admit( 'main-status-update', AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, status('pane', 'refresh'), { moveToEnd: true } ) - expect(adapter.listingOrder('pane')).toBe(40) + expect(adapter.listingOrder('pane')).toBe(41) adapter.move('pane', 'moved-pane') - expect(adapter.listingOrder('moved-pane')).toBe(40) + expect(adapter.listingOrder('moved-pane')).toBe(42) adapter.delete('moved-pane') adapter.admit( @@ -159,6 +172,6 @@ describe('legacy agent-status adapter', () => { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, status('moved-pane', 'new lifecycle') ) - expect(adapter.listingOrder('moved-pane')).toBe(41) + expect(adapter.listingOrder('moved-pane')).toBe(43) }) }) diff --git a/src/shared/agent-status-legacy-adapter.ts b/src/shared/agent-status-legacy-adapter.ts index f173a32ddb2..ccad16b5a78 100644 --- a/src/shared/agent-status-legacy-adapter.ts +++ b/src/shared/agent-status-legacy-adapter.ts @@ -69,6 +69,11 @@ export function canAdmitLegacyAgentStatus( export type AgentStatusLegacyAdapter = { readonly view: ReadonlyMap + canAdmit( + caller: AgentStatusLegacyIngressCaller, + mode: AgentStatusLegacyAdmissionMode, + entry: AgentHookEventPayload + ): boolean admit( caller: AgentStatusLegacyIngressCaller, mode: AgentStatusLegacyAdmissionMode, @@ -140,14 +145,19 @@ export function createAgentStatusLegacyAdapter( const nextListingOrder = options.nextListingOrder ?? (() => nextLocalListingOrder++) const isCanonicalPaneKey = options.isCanonicalPaneKey ?? (() => false) const view = createReadonlyView(entries) + const canAdmit: AgentStatusLegacyAdapter['canAdmit'] = (caller, mode, entry) => + entry.structuredHost === undefined && + !isCanonicalPaneKey(entry.paneKey) && + canAdmitLegacyAgentStatus(caller, mode) return { view, + canAdmit, admit: (caller, mode, entry, admitOptions = {}) => { - if (isCanonicalPaneKey(entry.paneKey) || !canAdmitLegacyAgentStatus(caller, mode)) { + if (!canAdmit(caller, mode, entry)) { return false } - if (!listingOrderByPaneKey.has(entry.paneKey)) { + if (!listingOrderByPaneKey.has(entry.paneKey) || admitOptions.moveToEnd) { const order = nextListingOrder() if (!Number.isSafeInteger(order) || order < 0) { throw new RangeError( @@ -181,7 +191,6 @@ export function createAgentStatusLegacyAdapter( } const movedKey = `${toPaneKey}${key.slice(fromPaneKey.length)}` const priorTargetOrder = listingOrderByPaneKey.get(movedKey) - const sourceOrder = listingOrderByPaneKey.get(key) entries.delete(key) listingOrderByPaneKey.delete(key) if (isCanonicalPaneKey(movedKey)) { @@ -190,8 +199,8 @@ export function createAgentStatusLegacyAdapter( entries.set(movedKey, value) if (priorTargetOrder !== undefined) { listingOrderByPaneKey.set(movedKey, priorTargetOrder) - } else if (sourceOrder !== undefined) { - listingOrderByPaneKey.set(movedKey, sourceOrder) + } else { + listingOrderByPaneKey.set(movedKey, nextListingOrder()) } } }, diff --git a/src/shared/agent-status-store-byte-budget.test.ts b/src/shared/agent-status-store-byte-budget.test.ts new file mode 100644 index 00000000000..ef49288d761 --- /dev/null +++ b/src/shared/agent-status-store-byte-budget.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from 'vitest' +import { createAgentStatusStore } from './agent-status-store' +import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' +import { + deserializeAgentStatusStoreSnapshot, + serializeAgentStatusStoreSnapshot +} from './agent-status-store-persistence' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +it('rejects cumulative snapshot overflow atomically even when each mutation fits', () => { + const subject = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'folder-a', workspaceKind: 'folder' }, + 'session_11111111-1111-4111-8111-111111111111' + ) + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject } })).not.toBeNull() + const batch = (offset: number) => ({ + facts: Array.from({ length: 1024 }, (_, index) => ({ + subject, + key: `fact-${offset + index}`, + value: 'x'.repeat(4096) + })) + }) + for (const offset of [0, 1024, 2048]) { + expect(store.applyMutation(batch(offset))).not.toBeNull() + } + const before = store.getSnapshot() + expect(store.applyMutation(batch(3072))).toBeNull() + expect(store.getSnapshot()).toEqual(before) + expect(serializeAgentStatusStoreSnapshot(before).length).toBeLessThan( + AGENT_STATUS_STORE_LIMITS.serializedBytes + ) +}) + +it('can deserialize a dense valid snapshot within the declared record and byte budgets', () => { + const subject = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'folder-a', workspaceKind: 'folder' }, + 'session_11111111-1111-4111-8111-111111111111' + ) + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect( + store.applySnapshot({ + version: 1, + epoch: 'persisted', + revision: 1, + parents: [{ subject, revision: 1 }], + children: Array.from({ length: 8192 }, (_, index) => ({ + childWorkId: `child-${index}`, + parent: subject, + provider: 'claude', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 10, + observedAt: 10, + stoppable: false, + invocation: { invocationId: 'invocation-1', generation: 1 }, + provenance: { source: 'structured-session', producerId: 'journal' }, + revision: 1 + })), + aliases: [], + facts: Array.from({ length: 8192 }, (_, index) => ({ + subject, + key: `fact-${index}`, + value: true, + revision: 1 + })), + tombstones: [] + }) + ).toBe(true) + const snapshot = store.getSnapshot() + expect(deserializeAgentStatusStoreSnapshot(serializeAgentStatusStoreSnapshot(snapshot))).toEqual( + snapshot + ) +}) diff --git a/src/shared/agent-status-store-byte-budget.ts b/src/shared/agent-status-store-byte-budget.ts new file mode 100644 index 00000000000..bfa1d6ef7d6 --- /dev/null +++ b/src/shared/agent-status-store-byte-budget.ts @@ -0,0 +1,50 @@ +import { + AGENT_STATUS_STORE_LIMITS, + AGENT_STATUS_STORE_SNAPSHOT_VERSION +} from './agent-status-store-contract' +import type { AgentStatusStoreState } from './agent-status-store-state' +import { measureUtf8ByteLength } from './utf8-byte-limits' + +const recordBytes = new WeakMap() + +function serializedBytes(record: object): number { + const cached = recordBytes.get(record) + if (cached !== undefined) { + return cached + } + const bytes = measureUtf8ByteLength(JSON.stringify(record)).byteLength + if (Object.isFrozen(record)) { + recordBytes.set(record, bytes) + } + return bytes +} + +/** Enforce the complete snapshot budget before commit without allocating a full snapshot. */ +export function agentStatusStoreFitsByteBudget(state: AgentStatusStoreState): boolean { + let bytes = serializedBytes({ + version: AGENT_STATUS_STORE_SNAPSHOT_VERSION, + epoch: state.epoch, + revision: state.revision, + parents: [], + children: [], + aliases: [], + facts: [], + tombstones: [] + }) + for (const records of [ + state.parents, + state.children, + state.aliases, + state.facts, + state.tombstones + ]) { + bytes += Math.max(0, records.size - 1) + for (const record of records.values()) { + bytes += serializedBytes(record) + if (bytes > AGENT_STATUS_STORE_LIMITS.serializedBytes) { + return false + } + } + } + return bytes <= AGENT_STATUS_STORE_LIMITS.serializedBytes +} diff --git a/src/shared/agent-status-store-child-queries.ts b/src/shared/agent-status-store-child-queries.ts new file mode 100644 index 00000000000..4850c57e82d --- /dev/null +++ b/src/shared/agent-status-store-child-queries.ts @@ -0,0 +1,34 @@ +import { + serializeAgentChildWorkAliasKey, + type AgentChildWorkAliasInput, + type AgentChildWorkAliasRecord +} from './agent-status-child-work-alias' +import { deserializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' +import { + deepFreezeAgentStatusStoreValue, + type AgentStatusStoreState +} from './agent-status-store-state' + +/** Retired bindings fence delayed observations even after their child/history is removed. */ +export function resolveAgentStatusChildBindings( + state: AgentStatusStoreState, + aliases: AgentChildWorkAliasInput[] +): AgentChildWorkAliasRecord[] { + const keys = new Set(aliases.map(serializeAgentChildWorkAliasKey)) + const matches: AgentChildWorkAliasRecord[] = [] + for (const alias of state.aliases.values()) { + if (keys.has(serializeAgentChildWorkAliasKey(alias))) { + matches.push(alias) + } + } + for (const tombstone of state.tombstones.values()) { + if (tombstone.entity !== 'alias' || state.aliases.has(tombstone.key)) { + continue + } + const alias = deserializeAgentChildWorkBindingKey(tombstone.key) + if (alias && keys.has(serializeAgentChildWorkAliasKey(alias))) { + matches.push(deepFreezeAgentStatusStoreValue({ ...alias, revision: tombstone.revision })) + } + } + return matches +} diff --git a/src/shared/agent-status-store-codec.ts b/src/shared/agent-status-store-codec.ts index 5a0d6fc2878..326fec5aa72 100644 --- a/src/shared/agent-status-store-codec.ts +++ b/src/shared/agent-status-store-codec.ts @@ -28,7 +28,7 @@ import { parseAgentStatusSubject } from './agent-status-subject' import { measureUtf8ByteLength } from './utf8-byte-limits' const MAX_EPOCH_LENGTH = 256 -const MAX_TOMBSTONE_KEY_LENGTH = 4_096 +const MAX_TOMBSTONE_KEY_LENGTH = 32_768 function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/src/shared/agent-status-store-mutation.ts b/src/shared/agent-status-store-mutation.ts index b8338523e64..eb1adf604a6 100644 --- a/src/shared/agent-status-store-mutation.ts +++ b/src/shared/agent-status-store-mutation.ts @@ -1,8 +1,8 @@ +import { parseAgentChildWorkAliasRecord } from './agent-status-child-work-alias' import { - deserializeAgentChildWorkAliasKey, - parseAgentChildWorkAliasRecord, - serializeAgentChildWorkAliasKey -} from './agent-status-child-work-alias' + deserializeAgentChildWorkBindingKey, + serializeAgentChildWorkBindingKey +} from './agent-status-child-work-binding' import { parseAgentChildWorkRecord } from './agent-status-child-work-codec' import type { AgentStatusStoreMutation, @@ -124,7 +124,7 @@ function applyExplicitTombstone( if (tombstone.entity === 'child') { removeChild(state, tombstone.key, revision, removedChildWorkIds) } else if (tombstone.entity === 'alias') { - if (!deserializeAgentChildWorkAliasKey(tombstone.key)) { + if (!deserializeAgentChildWorkBindingKey(tombstone.key)) { return false } removeAlias(state, tombstone.key, revision) @@ -195,7 +195,7 @@ function upsertAliases( return false } state.aliases.set( - serializeAgentChildWorkAliasKey(record), + serializeAgentChildWorkBindingKey(record), deepFreezeAgentStatusStoreValue(record) ) } @@ -232,7 +232,7 @@ export function applyAgentStatusStoreMutation( removeChild(next, childWorkId, revision, removedChildWorkIds) } for (const key of mutation.removeAliases ?? []) { - if (!deserializeAgentChildWorkAliasKey(key)) { + if (!deserializeAgentChildWorkBindingKey(key)) { return null } removeAlias(next, key, revision) diff --git a/src/shared/agent-status-store-persistence.ts b/src/shared/agent-status-store-persistence.ts index 0f72203ca3e..ffab63d4557 100644 --- a/src/shared/agent-status-store-persistence.ts +++ b/src/shared/agent-status-store-persistence.ts @@ -7,7 +7,7 @@ import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit import { measureUtf8ByteLength } from './utf8-byte-limits' const SNAPSHOT_STRUCTURE_LIMITS = { - structuralTokens: 512 * 1024, + structuralTokens: AGENT_STATUS_STORE_LIMITS.serializedBytes, nestingDepth: 32 } as const diff --git a/src/shared/agent-status-store-reopen.test.ts b/src/shared/agent-status-store-reopen.test.ts new file mode 100644 index 00000000000..633d6e9a5b6 --- /dev/null +++ b/src/shared/agent-status-store-reopen.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { createAgentStatusStore } from './agent-status-store' +import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' +import { + makePtyRunAgentStatusSubject, + makeStructuredAgentStatusSubject +} from './agent-status-subject' + +const scope = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-one', + workspaceKind: 'folder' +} as const +const subject = makeStructuredAgentStatusSubject(scope, 'durable-session') + +describe('structured parent reopening', () => { + it('reopens a removed structured parent and fences replay from before the reopen', () => { + const owner = createAgentStatusStore({ epoch: 'host', mode: 'authority' }) + const replica = createAgentStatusStore({ epoch: 'reader', mode: 'replica' }) + const oldPublication = owner.applyMutation({ parent: { subject, firstObservedAt: 10 } }) + expect(oldPublication).not.toBeNull() + expect(replica.applySnapshot(owner.getSnapshot())).toBe(true) + const removal = owner.applyMutation({ removeParent: subject }) + expect(removal).not.toBeNull() + expect(replica.applyTransportEnvelope(removal)).toBe(true) + + const reopened = owner.applyMutation({ parent: { subject, firstObservedAt: 30 } }) + expect(reopened).not.toBeNull() + expect(replica.applyTransportEnvelope(reopened)).toBe(true) + expect(replica.getParent(subject)).toEqual(owner.getParent(subject)) + expect(replica.applyTransportEnvelope(oldPublication)).toBe(false) + expect(replica.getSnapshot()).toEqual(owner.getSnapshot()) + + // The revision envelope, not the tombstone's presence, is what fences the stale replay. + expect( + owner.applyMutation({ + removeChildren: Array.from( + { length: AGENT_STATUS_STORE_LIMITS.tombstones + 1 }, + (_, index) => `unused-child-${index}` + ) + }) + ).not.toBeNull() + expect(owner.getSnapshot().tombstones.some((item) => item.entity === 'parent')).toBe(false) + expect(replica.applySnapshot(owner.getSnapshot())).toBe(true) + expect(replica.applyTransportEnvelope(oldPublication)).toBe(false) + expect(replica.getParent(subject)?.firstObservedAt).toBe(30) + }) + + it('fences a republication only inside the removing mutation, for every subject kind', () => { + const owner = createAgentStatusStore({ epoch: 'host', mode: 'authority' }) + const pty = makePtyRunAgentStatusSubject(scope, 'retired-run') + expect(owner.applyMutation({ parent: { subject: pty } })).not.toBeNull() + expect(owner.applyMutation({ removeParent: pty })).not.toBeNull() + + // Same mutation: the tombstone shares this revision, so it outranks the republication. + const contradiction = owner.getSnapshot() + expect(owner.applyMutation({ removeParent: subject, parent: { subject } })).toBeNull() + expect(owner.getSnapshot()).toEqual(contradiction) + + // A later mutation outranks the tombstone regardless of kind — PTY runs included. + expect(owner.applyMutation({ parent: { subject: pty } })).not.toBeNull() + expect(owner.getParent(pty)).not.toBeNull() + + expect(owner.applyMutation({})).toBeNull() + }) +}) diff --git a/src/shared/agent-status-store-state.ts b/src/shared/agent-status-store-state.ts index 42749f270ca..249830a5aa5 100644 --- a/src/shared/agent-status-store-state.ts +++ b/src/shared/agent-status-store-state.ts @@ -1,15 +1,18 @@ import { - deserializeAgentChildWorkAliasKey, parseAgentChildWorkAliasRecord, - serializeAgentChildWorkAliasKey, type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' +import { + deserializeAgentChildWorkBindingKey, + serializeAgentChildWorkBindingKey +} from './agent-status-child-work-binding' import { agentChildWorkBelongsTo, agentChildWorkFencesEqual, type AgentChildWorkRecord } from './agent-status-child-work' import { parseAgentChildWorkRecord } from './agent-status-child-work-codec' +import { agentStatusStoreFitsByteBudget } from './agent-status-store-byte-budget' import { AGENT_STATUS_STORE_LIMITS, AGENT_STATUS_STORE_SNAPSHOT_VERSION, @@ -33,7 +36,6 @@ import { parseAgentStatusParentRecord, type AgentStatusParentRecord } from './agent-status-store-parent' -import { isAgentStatusStoreSnapshotWithinByteLimit } from './agent-status-store-snapshot-budget' import { deserializeAgentStatusSubject, serializeAgentStatusSubject } from './agent-status-subject' export type AgentStatusStoreState = { @@ -149,7 +151,7 @@ export function validateAgentStatusStoreState(state: AgentStatusStoreState): boo const child = state.children.get(alias.childWorkId) const tombstone = state.tombstones.get(agentStatusTombstoneMapKey('alias', key)) if ( - key !== serializeAgentChildWorkAliasKey(alias) || + key !== serializeAgentChildWorkBindingKey(alias) || alias.revision > state.revision || !child || !agentChildWorkBelongsTo(child, alias.parent) || @@ -176,13 +178,13 @@ export function validateAgentStatusStoreState(state: AgentStatusStoreState): boo if ( item.revision > state.revision || (item.entity === 'parent' && !deserializeAgentStatusSubject(item.key)) || - (item.entity === 'alias' && !deserializeAgentChildWorkAliasKey(item.key)) || + (item.entity === 'alias' && !deserializeAgentChildWorkBindingKey(item.key)) || (item.entity === 'fact' && !deserializeAgentStatusFactKey(item.key)) ) { return false } } - return isAgentStatusStoreSnapshotWithinByteLimit(state) + return agentStatusStoreFitsByteBudget(state) } export function snapshotFromAgentStatusStoreState( @@ -224,7 +226,7 @@ export function agentStatusStoreStateFromSnapshot( if (!record) { return null } - const key = serializeAgentChildWorkAliasKey(record) + const key = serializeAgentChildWorkBindingKey(record) if (state.aliases.has(key)) { return null } diff --git a/src/shared/agent-status-store.ts b/src/shared/agent-status-store.ts index f4c286f1d23..e59919096ab 100644 --- a/src/shared/agent-status-store.ts +++ b/src/shared/agent-status-store.ts @@ -2,9 +2,11 @@ import { agentChildWorkBelongsTo, type AgentChildWorkRecord } from './agent-stat import { serializeAgentChildWorkAliasKey, type AgentChildWorkAliasIdentity, + type AgentChildWorkAliasInput, type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' import { parseAgentChildWorkRecord } from './agent-status-child-work-codec' +import { resolveAgentStatusChildBindings } from './agent-status-store-child-queries' import type { AgentStatusStoreSnapshot } from './agent-status-store-contract' import { isAgentStatusStoreEpoch, @@ -43,6 +45,7 @@ export type AgentStatusStore = { getAlias(identity: AgentChildWorkAliasIdentity): AgentChildWorkAliasRecord | null getAliasesForChild(childWorkId: string): AgentChildWorkAliasRecord[] getRunAliasIndex(): AgentStatusRunAliasIndex + resolveChildAliases(aliases: AgentChildWorkAliasInput[]): AgentChildWorkAliasRecord[] getSnapshot(): AgentStatusStoreSnapshot applyMutation(mutation: unknown): AgentStatusMutationEnvelope | null applySnapshot(snapshot: unknown): boolean @@ -62,6 +65,9 @@ export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): let snapshotApplied = options.mode === 'authority' const store: AgentStatusStore = { + resolveChildAliases(aliases) { + return resolveAgentStatusChildBindings(state, aliases) + }, getParent(subject) { const parsed = parseAgentStatusSubject(subject) if (!parsed) {