mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
feat(agent-status): route structured status through canonical ownership and fence child lifetimes
Restacked onto the canonical store and child-work contract. Completing that restack drops the `reopenStructuredParent` mutation flag this change had carried, along with its contract field, its codec branch, and its single call site in structured ingest, which passed a hardcoded `true`. The flag was a narrow escape hatch from the absolute `tombstones.has(...)` rule that governed parent upserts in this branch's original base. The canonical store replaces that rule with a revision envelope, because a bounded store compacts tombstones away and a presence-based guard silently stops fencing once one is evicted. With the envelope deciding the outcome, the escape hatch has nothing left to escape from, so removing it changes no production behaviour. `agent-status-store-reopen.test.ts` is rewritten against the envelope: the reopen case now pins that an unflagged republication succeeds while replay from before the reopen stays fenced even after the parent tombstone is compacted away, and the second case pins where the guard genuinely bites — a republication inside the removing mutation itself, for every subject kind.
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>[] = []
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string>()
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,7 @@ export const _internals = {
|
||||
parseFormEncodedBody,
|
||||
resetCachesForTests: (): void => {
|
||||
clearAllListenerCaches(agentHookServer._getStateForTests())
|
||||
agentHookServer._resetCanonicalStatusForTests()
|
||||
agentHookServer._resetRowOwnershipForTests()
|
||||
agentHookServer._resetPromptSentDedupeForTests()
|
||||
agentHookServer._resetConnectionTimestampWatermarksForTests()
|
||||
|
||||
@@ -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<string, AgentStatusStructuredSessionSubject>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number>()
|
||||
protected readonly canonicalSubjectsByPane = new Map<
|
||||
string,
|
||||
Map<string, AgentStatusStructuredSessionSubject>
|
||||
>()
|
||||
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<typeof createServer> | 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+98
-6
@@ -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<string, StructuredAgentSessionHostSession>
|
||||
journal: AgentSessionJournal
|
||||
records: Map<string, AgentSessionRecord>
|
||||
}> {
|
||||
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<typeof attachStructuredAgentSession>[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()
|
||||
|
||||
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-17
@@ -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<ReturnType<typeof openJournal>>
|
||||
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<StructuredAgentSessionStatusSink['forget']>[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([
|
||||
|
||||
@@ -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<string, StatusFeedSession>
|
||||
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<string, StructuredAgentSessionStatusSubscriber>()
|
||||
private readonly published = new Map<string, AgentSessionStatusSummary>()
|
||||
// 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)
|
||||
}
|
||||
|
||||
+80
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, AgentStatusStructuredSessionSubject>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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,
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
]),
|
||||
|
||||
@@ -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> = {}): AgentSessionStatusSummary {
|
||||
return {
|
||||
@@ -38,7 +48,7 @@ function summary(over: Partial<AgentSessionStatusSummary> = {}): 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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -96,6 +96,7 @@ function persistedStructuredWorkerPaneKeyIsValid(
|
||||
paneKey !== structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) &&
|
||||
parsed &&
|
||||
parsed.tabId === structuredAgentSessionTabId(sessionId) &&
|
||||
paneKey !== structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) &&
|
||||
isTerminalLeafId(parsed.leafId)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(<StructuredAgentSessionStatusBridge />)
|
||||
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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }])
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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> = {}
|
||||
): 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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<string>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,6 +69,11 @@ export function canAdmitLegacyAgentStatus(
|
||||
|
||||
export type AgentStatusLegacyAdapter = {
|
||||
readonly view: ReadonlyMap<string, AgentHookEventPayload>
|
||||
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())
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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<object, number>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user