Merge remote-tracking branch 'origin/main' into brennanb2025/claude-task-frame-rows

Two conflicts, both resolved keeping each side's intent:

- mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts: took
  main's version wholesale. The branch's Reflect.get -> target[key] edit was
  only there to clear the anti-slop gate, and main fixed the same violation
  independently before evolving the file further.

- src/main/claude/claude-structured-journal-translation.ts: main moved turn
  identity and the reopen latch into ClaudeOpenTurn so a client's Stop names
  the same turn the journal row carries; this branch added the typed
  background-task row wiring. Both survive. The extracted message journaler
  now takes the ClaudeOpenTurn itself rather than three closures over the old
  translator-local state, so the single-owner property main established holds
  across the extraction.
This commit is contained in:
Brennan Benson
2026-09-16 01:50:12 -07:00
1407 changed files with 186745 additions and 85655 deletions
@@ -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)
}
+133
View File
@@ -0,0 +1,133 @@
/**
* What a caller asks for when it wants an agent running somewhere, independent of which surface
* asked and of whether the answer turns out to be a structured session or a terminal.
*
* Every launch surface builds one of these: the renderer's agent tabs and workspace creates,
* mobile's create sheet and new-tab button, `orchestration.workerStart`, and the CLI. The host
* resolves it once — settings default plus per-launch feasibility from
* `structured-native-chat-launch-route` — so no surface carries its own copy of that decision.
*
* The intent deliberately does NOT name a mode. A caller states what it wants to happen, not how
* to deliver it; picking structured vs terminal is the host's job and is reported back in the
* receipt rather than requested here.
*/
import type { TuiAgent } from './tui-agent'
/** How a launch's initial text reaches the agent. */
export type AgentLaunchPromptDelivery =
/** Sent as the agent's first turn once it is ready. */
| 'submit'
/** Left unsent for the user to edit and send. Historically this forced a terminal, because a
* draft lived in the TUI's input and chat only mirrored it; a structured session accepts one
* directly, so it no longer decides the route. */
| 'draft'
export type AgentLaunchPrompt = {
text: string
delivery: AgentLaunchPromptDelivery
}
/**
* Where the agent lands.
*
* `create-worktree` is part of the intent rather than a separate call the caller makes first,
* because the route cannot be settled before the workspace exists: `agentSession.createSupport`
* can only answer for a workspace the host can resolve. Splitting the two is exactly what made
* every new-worktree launch a terminal — the worktree was created agent-first, so the structured
* branch below it was unreachable.
*/
export type AgentLaunchTarget =
/** A workspace that already exists, addressed by any selector the runtime resolves. */
| { kind: 'existing'; worktree: string }
/** A worktree this launch creates. `create` is the `worktree.create` request minus its agent
* fields — the launch owns those, so a caller cannot set a startup agent behind the router. */
| { kind: 'create-worktree'; create: Readonly<Record<string, unknown>> }
/** An existing terminal the caller wants reused rather than a fresh surface. Always resolves to a
* terminal agent: a running PTY keeps its execution transport. */
export type AgentLaunchReusedTerminal = { handle: string }
/**
* Facts that only the calling surface knows and that the route has to see. These are inputs to the
* decision, not requests: a caller states that it is passing custom agent arguments, and the host
* concludes that a terminal is required.
*/
export type AgentLaunchCustomization = {
/** Explicit per-launch agent argv. Only a TUI applies these. */
agentArgs?: string
/** A subdirectory the agent should start in. Only a TUI applies this. */
cwd?: string
}
export type AgentLaunchIntent = {
agent: TuiAgent
target: AgentLaunchTarget
prompt?: AgentLaunchPrompt
/** Seeded launch options, narrowed by the host to what a structured create accepts. */
sessionOptions?: Readonly<Record<string, unknown>>
reuseTerminal?: AgentLaunchReusedTerminal
customization?: AgentLaunchCustomization
}
/** The surface the host actually created. */
export type AgentLaunchOutcome =
| { kind: 'structured'; sessionId: string; handle: string }
| { kind: 'terminal'; handle: string; warning?: string }
/** Whether the launch text was delivered, for a caller that needs to report or retry it. */
export type AgentLaunchPromptReceipt = {
delivery: AgentLaunchPromptDelivery
delivered: boolean
}
export type AgentLaunchResult = {
outcome: AgentLaunchOutcome
/** The workspace the agent runs in, resolved or created. */
worktreeId: string
/** Why the outcome is what it is — always populated, so a downgrade is never silent. */
receipt: AgentLaunchModeReceipt
prompt?: AgentLaunchPromptReceipt
}
/** Restates `WorkerStartModeReceipt` in surface-neutral terms so orchestration's receipt and a
* mobile or renderer launch report the same vocabulary. */
export type AgentLaunchModeReceipt = {
mode: 'structured' | 'terminal'
/** The user's settings default for a new agent tab. */
preferred: 'structured' | 'terminal'
reason: string
/** One sentence, always present. */
detail: string
}
export function agentLaunchTargetIsCreate(
target: AgentLaunchTarget
): target is Extract<AgentLaunchTarget, { kind: 'create-worktree' }> {
return target.kind === 'create-worktree'
}
/** The agent fields a create payload must not carry: the launch owns placement, and a caller that
* sets one of these would route itself around the host's decision. */
export const AGENT_LAUNCH_RESERVED_CREATE_FIELDS = [
'startupAgent',
'startupCommand',
'startupPrompt',
'startupDraft',
'startupLaunchConfig',
'startupEnv',
'startupCommandDelivery'
] as const
/** Strips the reserved agent fields from a create payload. Callers migrating from
* `worktree.create` pass their existing params; this keeps a stale `startupAgent` from
* re-creating the agent-first path the router exists to replace. */
export function withoutReservedAgentCreateFields(
create: Readonly<Record<string, unknown>>
): Record<string, unknown> {
const stripped: Record<string, unknown> = { ...create }
for (const field of AGENT_LAUNCH_RESERVED_CREATE_FIELDS) {
delete stripped[field]
}
return stripped
}
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { createAgentChildWorkAdmission } from './agent-status-child-work-admission'
import { createAgentStatusStore } from './agent-status-store'
import {
makeStructuredAgentStatusSubject,
type AgentStatusExecutionScope,
type AgentStatusSubject
} from './agent-status-subject'
const SESSION_ID = 'session_11111111-1111-4111-8111-111111111111'
function subject(overrides: Partial<AgentStatusExecutionScope> = {}): AgentStatusSubject {
return makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree',
...overrides
},
SESSION_ID
)
}
function request(parent: AgentStatusSubject, kind: 'agent' | 'unknown') {
return {
parent,
provider: 'claude',
aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id' as const, alias: 'task-1' }],
fence: { invocationId: `invocation-${kind}`, generation: kind === 'unknown' ? 1 : 2 },
lifetime: 'current' as const,
kind,
state: 'working' as const,
membership: 'live' as const,
observedAt: 10,
stoppable: true,
provenance: { source: 'structured-session' as const, producerId: 'journal-1' }
}
}
describe('agent child-work admission collisions', () => {
it('rejects an adopt whose alias reclassification would capture another child', () => {
const parent = subject()
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
store.applyMutation({ parent: { subject: parent } })
const ids = ['child-1', 'child-2']
const admission = createAgentChildWorkAdmission(store, {
mintChildWorkId: () => ids.shift() ?? 'unexpected-child'
})
admission.announce(request(parent, 'unknown'))
admission.announce(request(parent, 'agent'))
const before = store.getSnapshot()
expect(
admission.adopt({
...request(parent, 'unknown'),
childWorkId: 'child-1',
expectedFence: { invocationId: 'invocation-unknown', generation: 1 },
kind: 'agent',
observedAt: 20
})
).toEqual({ accepted: false, reason: 'ambiguous' })
expect(store.getSnapshot()).toEqual(before)
})
it('rejects reparenting into an occupied alias scope without moving the child', () => {
const firstParent = subject()
const nextParent = subject({ workspaceId: 'workspace-2' })
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
store.applyMutation({ parent: { subject: firstParent } })
store.applyMutation({ parent: { subject: nextParent } })
const ids = ['child-1', 'child-2']
const admission = createAgentChildWorkAdmission(store, {
mintChildWorkId: () => ids.shift() ?? 'unexpected-child'
})
admission.announce(request(firstParent, 'agent'))
admission.announce(request(nextParent, 'agent'))
const before = store.getSnapshot()
expect(
admission.reparent({
childWorkId: 'child-1',
fromParent: firstParent,
toParent: nextParent,
expectedFence: { invocationId: 'invocation-agent', generation: 2 },
observedAt: 20
})
).toEqual({ accepted: false, reason: 'ambiguous' })
expect(store.getSnapshot()).toEqual(before)
})
})
@@ -0,0 +1,176 @@
import {
serializeAgentChildWorkAliasKey,
type AgentChildWorkAliasInput,
type AgentChildWorkAliasRecord
} from './agent-status-child-work-alias'
import {
agentChildWorkFencesEqual,
type AgentChildWorkId,
type AgentChildWorkInput,
type AgentChildWorkInvocationFence,
type AgentChildWorkKind,
type AgentChildWorkRecord
} from './agent-status-child-work'
import { parseAgentChildWorkInput } from './agent-status-child-work-codec'
import type {
AgentChildWorkAdmissionResult,
AgentChildWorkAdoptRequest,
AgentChildWorkAnnounceRequest,
AgentChildWorkObservationAlias,
AgentChildWorkObservationFields
} from './agent-status-child-work-admission'
import type { AgentStatusStore } from './agent-status-store'
import { agentStatusSubjectsEqual, type AgentStatusSubject } from './agent-status-subject'
const MAX_ALIASES_PER_ADMISSION = 32
export function rejectAgentChildWorkAdmission(
reason: Extract<AgentChildWorkAdmissionResult, { accepted: false }>['reason']
) {
return { accepted: false, reason } as const
}
export function findAgentChildWork(
store: AgentStatusStore,
childWorkId: string
): AgentChildWorkRecord | null {
return store.getChild(childWorkId)
}
export function agentChildWorkAliasesForChild(
store: AgentStatusStore,
childWorkId: string
): AgentChildWorkAliasRecord[] {
return store.getAliasesForChild(childWorkId)
}
export function buildAgentChildWorkAliases(
parent: AgentStatusSubject,
provider: string,
kind: AgentChildWorkKind,
aliases: AgentChildWorkObservationAlias[],
childWorkId: AgentChildWorkId,
fence: AgentChildWorkInvocationFence
): AgentChildWorkAliasInput[] | null {
if (aliases.length === 0 || aliases.length > MAX_ALIASES_PER_ADMISSION) {
return null
}
const built: AgentChildWorkAliasInput[] = []
const keys = new Set<string>()
try {
for (const alias of aliases) {
const candidate = { parent, provider, kind, ...alias, childWorkId, fence }
const key = serializeAgentChildWorkAliasKey(candidate)
if (keys.has(key)) {
return null
}
keys.add(key)
built.push(candidate)
}
} catch {
return null
}
return built
}
export function buildAgentChildWork(
request: AgentChildWorkObservationFields & {
parent: AgentStatusSubject
provider: string
},
childWorkId: string,
firstObservedAt: number,
invocation: AgentChildWorkInvocationFence,
previousInvocations?: AgentChildWorkInput['previousInvocations']
): AgentChildWorkInput | null {
return parseAgentChildWorkInput({
childWorkId,
parent: request.parent,
provider: request.provider,
kind: request.kind,
state: request.state,
membership: request.membership,
...(request.outcome !== undefined ? { outcome: request.outcome } : {}),
...(request.name !== undefined ? { name: request.name } : {}),
...(request.description !== undefined ? { description: request.description } : {}),
...(request.agentType !== undefined ? { agentType: request.agentType } : {}),
...(request.model !== undefined ? { model: request.model } : {}),
...(request.totalTokens !== undefined ? { totalTokens: request.totalTokens } : {}),
...(request.providerTiming !== undefined ? { providerTiming: request.providerTiming } : {}),
firstObservedAt,
observedAt: request.observedAt,
stoppable: request.stoppable,
invocation,
...(previousInvocations !== undefined ? { previousInvocations } : {}),
provenance: request.provenance
})
}
export function commitAgentChildWork(
store: AgentStatusStore,
child: AgentChildWorkInput,
aliases: AgentChildWorkAliasInput[],
created: boolean,
removeAliases: string[] = []
): AgentChildWorkAdmissionResult {
const envelope = store.applyMutation({
children: [child],
aliases,
...(removeAliases.length > 0 ? { removeAliases } : {})
})
return envelope
? { accepted: true, childWorkId: child.childWorkId, revision: envelope.revision, created }
: rejectAgentChildWorkAdmission('store-rejected')
}
export function updateExistingAgentChildWork(
store: AgentStatusStore,
request: AgentChildWorkAnnounceRequest | AgentChildWorkAdoptRequest,
child: AgentChildWorkRecord,
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,
child.firstObservedAt,
child.invocation,
child.previousInvocations
)
return updated
? commitAgentChildWork(store, updated, aliases, false, removeAliases)
: rejectAgentChildWorkAdmission('invalid')
}
export function resolveAgentChildWorkAliasRecords(
store: AgentStatusStore,
aliases: AgentChildWorkAliasInput[]
): AgentChildWorkAliasRecord[] {
return store.resolveChildAliases(aliases)
}
export function validateExistingAgentChildWork(
child: AgentChildWorkRecord | null,
parent: AgentStatusSubject,
provider: string,
expectedFence: AgentChildWorkInvocationFence
): AgentChildWorkAdmissionResult | null {
if (!child) {
return rejectAgentChildWorkAdmission('unknown-child')
}
if (!agentStatusSubjectsEqual(child.parent, parent) || child.provider !== provider) {
return rejectAgentChildWorkAdmission('ambiguous')
}
if (!agentChildWorkFencesEqual(child.invocation, expectedFence)) {
return rejectAgentChildWorkAdmission('stale-invocation')
}
return null
}
@@ -0,0 +1,222 @@
import { serializeAgentChildWorkBindingKey } from './agent-status-child-work-binding'
import { agentChildWorkFencesEqual, type AgentChildWorkId } from './agent-status-child-work'
import {
agentChildWorkAliasesForChild,
buildAgentChildWork,
buildAgentChildWorkAliases,
commitAgentChildWork,
findAgentChildWork,
rejectAgentChildWorkAdmission,
resolveAgentChildWorkAliasRecords,
updateExistingAgentChildWork,
validateExistingAgentChildWork
} from './agent-status-child-work-admission-core'
import type {
AgentChildWorkAdmissionResult,
AgentChildWorkAdoptRequest,
AgentChildWorkAnnounceRequest,
AgentChildWorkReparentRequest
} from './agent-status-child-work-admission'
import {
parseAgentChildWorkInput,
parseAgentChildWorkInvocationFence
} from './agent-status-child-work-codec'
import type { AgentStatusStore } from './agent-status-store'
import { agentStatusSubjectsEqual, parseAgentStatusSubject } from './agent-status-subject'
export function announceAgentChildWork(
store: AgentStatusStore,
mintChildWorkId: () => AgentChildWorkId,
request: AgentChildWorkAnnounceRequest
): AgentChildWorkAdmissionResult {
const parent = parseAgentStatusSubject(request.parent)
const fence = parseAgentChildWorkInvocationFence(request.fence)
if (!parent || !fence || !store.getParent(parent)) {
return rejectAgentChildWorkAdmission('invalid')
}
const lookupAliases = buildAgentChildWorkAliases(
parent,
request.provider,
request.kind,
request.aliases,
'unresolved-child',
fence
)
if (!lookupAliases) {
return rejectAgentChildWorkAdmission('invalid')
}
const bindings = resolveAgentChildWorkAliasRecords(store, lookupAliases)
const exact = bindings.filter((binding) => agentChildWorkFencesEqual(binding.fence, fence))
const exactIds = new Set(exact.map((binding) => binding.childWorkId))
if (request.lifetime === 'proven-new') {
if (exact.length > 0) {
return rejectAgentChildWorkAdmission('id-collision')
}
const candidateId = mintChildWorkId()
const aliases = buildAgentChildWorkAliases(
parent,
request.provider,
request.kind,
request.aliases,
candidateId,
fence
)
if (!aliases || findAgentChildWork(store, candidateId)) {
return rejectAgentChildWorkAdmission(aliases ? 'id-collision' : 'invalid')
}
const child = buildAgentChildWork(request, candidateId, request.observedAt, fence)
return child
? commitAgentChildWork(store, child, aliases, true)
: rejectAgentChildWorkAdmission('invalid')
}
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 ||
!agentStatusSubjectsEqual(child.parent, parent) ||
child.provider !== request.provider ||
child.kind !== request.kind
) {
return rejectAgentChildWorkAdmission('ambiguous')
}
if (!agentChildWorkFencesEqual(child.invocation, fence)) {
return rejectAgentChildWorkAdmission('stale-invocation')
}
const aliases = buildAgentChildWorkAliases(
parent,
request.provider,
request.kind,
request.aliases,
child.childWorkId,
fence
)
return aliases
? updateExistingAgentChildWork(store, request, child, aliases)
: rejectAgentChildWorkAdmission('invalid')
}
const candidateId = mintChildWorkId()
if (findAgentChildWork(store, candidateId)) {
return rejectAgentChildWorkAdmission('id-collision')
}
const aliases = buildAgentChildWorkAliases(
parent,
request.provider,
request.kind,
request.aliases,
candidateId,
fence
)
const child = buildAgentChildWork(request, candidateId, request.observedAt, fence)
return aliases && child
? commitAgentChildWork(store, child, aliases, true)
: rejectAgentChildWorkAdmission('invalid')
}
export function adoptAgentChildWork(
store: AgentStatusStore,
request: AgentChildWorkAdoptRequest
): AgentChildWorkAdmissionResult {
const child = findAgentChildWork(store, request.childWorkId)
const invalid = validateExistingAgentChildWork(
child,
request.parent,
request.provider,
request.expectedFence
)
if (invalid || !child) {
return invalid ?? rejectAgentChildWorkAdmission('unknown-child')
}
const aliases = buildAgentChildWorkAliases(
request.parent,
request.provider,
request.kind,
request.aliases,
child.childWorkId,
child.invocation
)
if (!aliases) {
return rejectAgentChildWorkAdmission('invalid')
}
const collisions = resolveAgentChildWorkAliasRecords(store, aliases).filter(
(binding) => binding.childWorkId !== child.childWorkId
)
if (collisions.length > 0) {
return rejectAgentChildWorkAdmission('ambiguous')
}
const oldAliases = agentChildWorkAliasesForChild(store, child.childWorkId)
const removeAliases = oldAliases
.filter((alias) => alias.kind !== request.kind)
.map(serializeAgentChildWorkBindingKey)
const reclassified = oldAliases.map((alias) => ({
parent: alias.parent,
provider: alias.provider,
segmentId: alias.segmentId,
kind: request.kind,
aliasKind: alias.aliasKind,
alias: alias.alias,
childWorkId: alias.childWorkId,
fence: alias.fence
}))
const unique = new Map(
[...reclassified, ...aliases].map((alias) => [serializeAgentChildWorkBindingKey(alias), alias])
)
const reclassifiedCollisions = resolveAgentChildWorkAliasRecords(store, [
...unique.values()
]).filter((binding) => binding.childWorkId !== child.childWorkId)
if (reclassifiedCollisions.length > 0) {
return rejectAgentChildWorkAdmission('ambiguous')
}
return updateExistingAgentChildWork(store, request, child, [...unique.values()], removeAliases)
}
export function reparentAgentChildWork(
store: AgentStatusStore,
request: AgentChildWorkReparentRequest
): AgentChildWorkAdmissionResult {
const child = findAgentChildWork(store, request.childWorkId)
if (
!child ||
!agentStatusSubjectsEqual(child.parent, request.fromParent) ||
!agentChildWorkFencesEqual(child.invocation, request.expectedFence) ||
!store.getParent(request.toParent) ||
request.observedAt < child.observedAt
) {
return rejectAgentChildWorkAdmission(child ? 'stale-invocation' : 'unknown-child')
}
const oldAliases = agentChildWorkAliasesForChild(store, child.childWorkId)
const aliases = oldAliases.map((alias) => ({
parent: request.toParent,
provider: alias.provider,
segmentId: alias.segmentId,
kind: alias.kind,
aliasKind: alias.aliasKind,
alias: alias.alias,
childWorkId: alias.childWorkId,
fence: alias.fence
}))
const collisions = resolveAgentChildWorkAliasRecords(store, aliases).filter(
(binding) => binding.childWorkId !== child.childWorkId
)
if (collisions.length > 0) {
return rejectAgentChildWorkAdmission('ambiguous')
}
const { revision: _revision, ...childInput } = child
const moved = parseAgentChildWorkInput({
...childInput,
parent: request.toParent,
observedAt: request.observedAt
})
return moved
? commitAgentChildWork(
store,
moved,
aliases,
false,
oldAliases.map(serializeAgentChildWorkBindingKey)
)
: rejectAgentChildWorkAdmission('invalid')
}
@@ -0,0 +1,359 @@
import { describe, expect, it, vi } from 'vitest'
import {
createAgentChildWorkAdmission,
type AgentChildWorkAnnounceRequest
} from './agent-status-child-work-admission'
import { AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX } from './agent-status-child-work'
import { serializeAgentChildWorkAliasKey } from './agent-status-child-work-alias'
import { createAgentStatusStore } from './agent-status-store'
import {
makeStructuredAgentStatusSubject,
type AgentStatusExecutionScope,
type AgentStatusSubject
} from './agent-status-subject'
const SESSION_ID = 'session_11111111-1111-4111-8111-111111111111'
function subject(overrides: Partial<AgentStatusExecutionScope> = {}): AgentStatusSubject {
return makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree',
...overrides
},
SESSION_ID
)
}
function announce(
parent: AgentStatusSubject,
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(parents: AgentStatusSubject[] = [subject()]) {
const ids = ['child-1', 'child-2', 'child-3', 'child-4']
const mintChildWorkId = vi.fn(() => ids.shift() ?? 'child-overflow')
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
for (const parent of parents) {
expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull()
}
return {
store,
mintChildWorkId,
admission: createAgentChildWorkAdmission(store, { mintChildWorkId })
}
}
describe('agent child-work admission', () => {
it('keeps one host id across re-announcement with another tool-use alias', () => {
const parent = subject()
const { admission, mintChildWorkId, store } = setup([parent])
expect(admission.announce(announce(parent))).toMatchObject({
accepted: true,
childWorkId: 'child-1',
created: true
})
expect(
admission.announce(
announce(parent, {
aliases: [
{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' },
{ segmentId: 'segment-1', aliasKind: 'tool_use_id', alias: 'tool-2' }
],
observedAt: 11
})
)
).toMatchObject({ accepted: true, childWorkId: 'child-1', created: false })
expect(mintChildWorkId).toHaveBeenCalledTimes(1)
expect(store.getChildren(parent)).toHaveLength(1)
expect(store.getSnapshot().aliases).toHaveLength(2)
})
it('admits a child without materializing the entire store snapshot', () => {
const parent = subject()
const { admission, store } = setup([parent])
const snapshot = vi.spyOn(store, 'getSnapshot')
expect(admission.announce(announce(parent)).accepted).toBe(true)
expect(admission.announce(announce(parent, { observedAt: 11 })).accepted).toBe(true)
expect(snapshot).not.toHaveBeenCalled()
})
it('adopts and reclassifies a provisional child without changing its id', () => {
const parent = subject()
const { admission, store } = setup([parent])
const first = admission.announce(
announce(parent, {
kind: 'unknown',
aliases: [{ segmentId: 'segment-1', aliasKind: 'tool_use_id', alias: 'tool-1' }]
})
)
expect(first).toMatchObject({ accepted: true, childWorkId: 'child-1' })
expect(
admission.adopt({
...announce(parent, { kind: 'agent', observedAt: 12 }),
childWorkId: 'child-1',
expectedFence: { invocationId: 'invocation-1', generation: 1 },
aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }]
})
).toMatchObject({ accepted: true, childWorkId: 'child-1', created: false })
expect(store.getChildren(parent)).toMatchObject([{ childWorkId: 'child-1', kind: 'agent' }])
expect(store.getSnapshot().aliases.every((alias) => alias.kind === 'agent')).toBe(true)
})
it('preserves the logical id and prior outcome across an explicit resume fence', () => {
const parent = subject()
const { admission, store } = setup([parent])
admission.announce(
announce(parent, {
state: 'done',
membership: 'settled',
outcome: 'failed',
observedAt: 20
})
)
expect(
admission.resume({
...announce(parent, {
aliases: [{ segmentId: 'segment-2', aliasKind: 'task_id', alias: 'task-1' }],
observedAt: 30
}),
childWorkId: 'child-1',
expectedFence: { invocationId: 'invocation-1', generation: 1 },
nextFence: { invocationId: 'invocation-2', generation: 2 }
})
).toMatchObject({ accepted: true, childWorkId: 'child-1', created: false })
expect(store.getChildren(parent)[0]).toMatchObject({
childWorkId: 'child-1',
firstObservedAt: 20,
invocation: { invocationId: 'invocation-2', generation: 2 },
previousInvocations: [
{
fence: { invocationId: 'invocation-1', generation: 1 },
outcome: 'failed',
settledAt: 20
}
]
})
})
it('rejects a backwards resume generation before it can authorize stale stop input', () => {
const parent = subject()
const { admission, store } = setup([parent])
admission.announce(
announce(parent, {
fence: { invocationId: 'invocation-5', generation: 5 }
})
)
const before = store.getSnapshot()
expect(
admission.resume({
...announce(parent, { observedAt: 20 }),
childWorkId: 'child-1',
expectedFence: { invocationId: 'invocation-5', generation: 5 },
nextFence: { invocationId: 'invocation-4', generation: 4 }
})
).toEqual({ accepted: false, reason: 'stale-invocation' })
expect(store.getSnapshot()).toEqual(before)
})
it('retires aliases when their invocation fence ages out of bounded history', () => {
const parent = subject()
const { admission, store } = setup([parent])
expect(admission.announce(announce(parent)).accepted).toBe(true)
for (
let generation = 2;
generation <= AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX + 2;
generation += 1
) {
const reusesOldestAlias = generation === AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX + 2
expect(
admission.resume({
...announce(parent, {
aliases: [
{
segmentId: reusesOldestAlias ? 'segment-1' : `segment-${generation}`,
aliasKind: 'task_id',
alias: reusesOldestAlias ? 'task-1' : `task-${generation}`
}
],
observedAt: 10 + generation
}),
childWorkId: 'child-1',
expectedFence: {
invocationId: `invocation-${generation - 1}`,
generation: generation - 1
},
nextFence: { invocationId: `invocation-${generation}`, generation }
})
).toMatchObject({ accepted: true, childWorkId: 'child-1' })
}
const aliases = store.getAliasesForChild('child-1')
expect(aliases).toHaveLength(AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX + 1)
expect(aliases.some((entry) => entry.fence.generation === 1)).toBe(false)
expect(aliases.find((entry) => entry.alias === 'task-1')?.fence.generation).toBe(
AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX + 2
)
})
it('rejects delayed observations resolved through a previous invocation alias', () => {
const parent = subject()
const { admission, store } = setup([parent])
expect(admission.announce(announce(parent)).accepted).toBe(true)
expect(
admission.resume({
...announce(parent, {
aliases: [{ segmentId: 'segment-2', aliasKind: 'task_id', alias: 'task-2' }],
observedAt: 20
}),
childWorkId: 'child-1',
expectedFence: { invocationId: 'invocation-1', generation: 1 },
nextFence: { invocationId: 'invocation-2', generation: 2 }
})
).toMatchObject({ accepted: true, childWorkId: 'child-1' })
const before = store.getSnapshot()
expect(
admission.announce(
announce(parent, {
state: 'done',
membership: 'settled',
outcome: 'succeeded',
observedAt: 30
})
)
).toEqual({ accepted: false, reason: 'stale-invocation' })
expect(store.getSnapshot()).toEqual(before)
})
it('mints a distinct id for proven reuse and rejects delayed predecessor updates and stops', () => {
const parent = subject()
const { admission, store } = setup([parent])
admission.announce(
announce(parent, {
state: 'done',
membership: 'settled',
outcome: 'succeeded',
observedAt: 20
})
)
const successor = announce(parent, {
fence: { invocationId: 'invocation-2', generation: 2 },
lifetime: 'proven-new',
observedAt: 30
})
expect(admission.announce(successor)).toMatchObject({
accepted: true,
childWorkId: 'child-2',
created: true
})
expect(store.getChildren(parent).map((child) => child.childWorkId)).toEqual([
'child-1',
'child-2'
])
expect(admission.announce(announce(parent, { observedAt: 40 }))).toEqual({
accepted: false,
reason: 'stale-invocation'
})
expect(
admission.authorizeStop({
parent,
childWorkId: 'child-2',
expectedFence: { invocationId: 'invocation-1', generation: 1 }
})
).toBeNull()
expect(
admission.authorizeStop({
parent,
childWorkId: 'child-2',
expectedFence: { invocationId: 'invocation-2', generation: 2 }
})?.childWorkId
).toBe('child-2')
})
it('fails closed for settled or non-stoppable stop targets', () => {
const parent = subject()
const { admission } = setup([parent])
admission.announce(announce(parent, { stoppable: false }))
const stop = {
parent,
childWorkId: 'child-1',
expectedFence: { invocationId: 'invocation-1', generation: 1 }
}
expect(admission.authorizeStop(stop)).toBeNull()
const second = setup([parent])
second.admission.announce(
announce(parent, { state: 'done', membership: 'settled', outcome: 'succeeded' })
)
expect(second.admission.authorizeStop(stop)).toBeNull()
})
it('scopes identical aliases by parent, provider, segment and kind', () => {
const firstParent = subject()
const secondParent = subject({ executionHostId: 'ssh:host-a' })
const { admission, store } = setup([firstParent, secondParent])
const requests = [
announce(firstParent),
announce(secondParent),
announce(firstParent, { provider: 'codex' }),
announce(firstParent, {
aliases: [{ segmentId: 'segment-2', aliasKind: 'task_id', alias: 'task-1' }]
}),
announce(firstParent, { kind: 'workflow' })
]
for (const request of requests) {
expect(admission.announce(request).accepted).toBe(true)
}
expect(store.getSnapshot().children).toHaveLength(5)
expect(new Set(store.getSnapshot().aliases.map(serializeAgentChildWorkAliasKey)).size).toBe(5)
})
it('reparents a child and all aliases without reminting identity', () => {
const firstParent = subject()
const nextParent = subject({ workspaceId: 'workspace-2' })
const { admission, store, mintChildWorkId } = setup([firstParent, nextParent])
admission.announce(announce(firstParent))
expect(
admission.reparent({
childWorkId: 'child-1',
fromParent: firstParent,
toParent: nextParent,
expectedFence: { invocationId: 'invocation-1', generation: 1 },
observedAt: 20
})
).toMatchObject({ accepted: true, childWorkId: 'child-1', created: false })
expect(store.getChildren(firstParent)).toEqual([])
expect(store.getChildren(nextParent)[0]?.childWorkId).toBe('child-1')
expect(store.getSnapshot().aliases[0]?.parent).toEqual(nextParent)
expect(mintChildWorkId).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,116 @@
import type { AgentChildWorkAliasKind } from './agent-status-child-work-alias'
import type {
AgentChildWorkId,
AgentChildWorkInvocationFence,
AgentChildWorkKind,
AgentChildWorkMembership,
AgentChildWorkOutcome,
AgentChildWorkProviderTiming,
AgentChildWorkProvenance,
AgentChildWorkRecord,
AgentChildWorkState
} from './agent-status-child-work'
import {
adoptAgentChildWork,
announceAgentChildWork,
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'
export type AgentChildWorkObservationAlias = {
segmentId: string
aliasKind: AgentChildWorkAliasKind
alias: string
}
export type AgentChildWorkObservationFields = {
kind: AgentChildWorkKind
state: AgentChildWorkState
membership: AgentChildWorkMembership
outcome?: AgentChildWorkOutcome
name?: string
description?: string
agentType?: string
model?: string
totalTokens?: number
providerTiming?: AgentChildWorkProviderTiming
observedAt: number
stoppable: boolean
provenance: AgentChildWorkProvenance
}
export type AgentChildWorkAnnounceRequest = AgentChildWorkObservationFields & {
parent: AgentStatusSubject
provider: string
aliases: AgentChildWorkObservationAlias[]
fence: AgentChildWorkInvocationFence
lifetime: 'current' | 'proven-new'
}
export type AgentChildWorkAdoptRequest = AgentChildWorkObservationFields & {
parent: AgentStatusSubject
provider: string
childWorkId: AgentChildWorkId
expectedFence: AgentChildWorkInvocationFence
aliases: AgentChildWorkObservationAlias[]
}
export type AgentChildWorkResumeRequest = AgentChildWorkObservationFields & {
parent: AgentStatusSubject
provider: string
childWorkId: AgentChildWorkId
expectedFence: AgentChildWorkInvocationFence
nextFence: AgentChildWorkInvocationFence
aliases: AgentChildWorkObservationAlias[]
}
export type AgentChildWorkReparentRequest = {
childWorkId: AgentChildWorkId
fromParent: AgentStatusSubject
toParent: AgentStatusSubject
expectedFence: AgentChildWorkInvocationFence
observedAt: number
}
export type AgentChildWorkStopRequest = {
parent: AgentStatusSubject
childWorkId: AgentChildWorkId
expectedFence: AgentChildWorkInvocationFence
}
export type AgentChildWorkAdmissionResult =
| { accepted: true; childWorkId: AgentChildWorkId; revision: number; created: boolean }
| {
accepted: false
reason:
| 'invalid'
| 'ambiguous'
| 'stale-invocation'
| 'unknown-child'
| 'id-collision'
| 'store-rejected'
}
export type AgentChildWorkAdmission = {
announce(request: AgentChildWorkAnnounceRequest): AgentChildWorkAdmissionResult
adopt(request: AgentChildWorkAdoptRequest): AgentChildWorkAdmissionResult
resume(request: AgentChildWorkResumeRequest): AgentChildWorkAdmissionResult
reparent(request: AgentChildWorkReparentRequest): AgentChildWorkAdmissionResult
authorizeStop(request: AgentChildWorkStopRequest): AgentChildWorkRecord | null
}
export function createAgentChildWorkAdmission(
store: AgentStatusStore,
options: { mintChildWorkId: () => AgentChildWorkId }
): AgentChildWorkAdmission {
return {
announce: (request) => announceAgentChildWork(store, options.mintChildWorkId, request),
adopt: (request) => adoptAgentChildWork(store, request),
resume: (request) => resumeAgentChildWork(store, request),
reparent: (request) => reparentAgentChildWork(store, request),
authorizeStop: (request) => authorizeAgentChildWorkStop(store, request)
}
}
+214
View File
@@ -0,0 +1,214 @@
import {
agentChildWorkFencesEqual,
type AgentChildWorkId,
type AgentChildWorkInvocationFence,
type AgentChildWorkKind
} from './agent-status-child-work'
import { parseAgentChildWorkInvocationFence } from './agent-status-child-work-codec'
import {
deserializeAgentStatusSubject,
parseAgentStatusSubject,
serializeAgentStatusSubject,
type AgentStatusSubject
} from './agent-status-subject'
const CHILD_ALIAS_KEY_PREFIX = 'agent-child-work-alias-v1:'
const MAX_ALIAS_PART_LENGTH = 512
export type AgentChildWorkAliasKind = 'task_id' | 'tool_use_id'
export type AgentChildWorkAliasIdentity = Pick<
AgentChildWorkAliasInput,
'parent' | 'provider' | 'segmentId' | 'kind' | 'aliasKind' | 'alias'
>
export type AgentChildWorkAliasInput = {
parent: AgentStatusSubject
provider: string
segmentId: string
kind: AgentChildWorkKind
aliasKind: AgentChildWorkAliasKind
alias: string
childWorkId: AgentChildWorkId
fence: AgentChildWorkInvocationFence
}
export type AgentChildWorkAliasRecord = AgentChildWorkAliasInput & {
revision: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasOnlyKeys(
record: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = []
): boolean {
const keys = Object.keys(record)
return (
required.every((key) => Object.hasOwn(record, key)) &&
keys.every((key) => required.includes(key) || optional.includes(key))
)
}
function isBoundedString(value: unknown): value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > MAX_ALIAS_PART_LENGTH ||
value !== value.trim()
) {
return false
}
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x1f || code === 0x7f) {
return false
}
}
return true
}
function isRevision(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function isKind(value: unknown): value is AgentChildWorkKind {
return (
value === 'agent' ||
value === 'workflow' ||
value === 'command' ||
value === 'monitor' ||
value === 'unknown'
)
}
export function parseAgentChildWorkAliasInput(value: unknown): AgentChildWorkAliasInput | null {
if (
!isRecord(value) ||
!hasOnlyKeys(value, [
'parent',
'provider',
'segmentId',
'kind',
'aliasKind',
'alias',
'childWorkId',
'fence'
]) ||
!isBoundedString(value.provider) ||
!isBoundedString(value.segmentId) ||
!isKind(value.kind) ||
(value.aliasKind !== 'task_id' && value.aliasKind !== 'tool_use_id') ||
!isBoundedString(value.alias) ||
!isBoundedString(value.childWorkId)
) {
return null
}
const parent = parseAgentStatusSubject(value.parent)
const fence = parseAgentChildWorkInvocationFence(value.fence)
if (!parent || !fence) {
return null
}
return {
parent,
provider: value.provider,
segmentId: value.segmentId,
kind: value.kind,
aliasKind: value.aliasKind,
alias: value.alias,
childWorkId: value.childWorkId,
fence
}
}
export function parseAgentChildWorkAliasRecord(value: unknown): AgentChildWorkAliasRecord | null {
if (!isRecord(value) || !isRevision(value.revision)) {
return null
}
const input = { ...value }
delete input.revision
const parsed = parseAgentChildWorkAliasInput(input)
return parsed ? { ...parsed, revision: value.revision } : null
}
export function serializeAgentChildWorkAliasKey(alias: AgentChildWorkAliasIdentity): string {
const parsed = parseAgentChildWorkAliasInput({
parent: alias.parent,
provider: alias.provider,
segmentId: alias.segmentId,
kind: alias.kind,
aliasKind: alias.aliasKind,
alias: alias.alias,
childWorkId: 'key-only',
fence: { invocationId: 'key-only', generation: 0 }
})
if (!parsed) {
throw new Error('Invalid agent child-work alias')
}
return `${CHILD_ALIAS_KEY_PREFIX}${JSON.stringify([
serializeAgentStatusSubject(parsed.parent),
parsed.provider,
parsed.segmentId,
parsed.kind,
parsed.aliasKind,
parsed.alias
])}`
}
export function deserializeAgentChildWorkAliasKey(
value: string
): AgentChildWorkAliasIdentity | null {
if (!value.startsWith(CHILD_ALIAS_KEY_PREFIX)) {
return null
}
let tuple: unknown
try {
tuple = JSON.parse(value.slice(CHILD_ALIAS_KEY_PREFIX.length))
} catch {
return null
}
if (!Array.isArray(tuple) || tuple.length !== 6) {
return null
}
const [parentKey, provider, segmentId, kind, aliasKind, alias] = tuple
if (typeof parentKey !== 'string') {
return null
}
const parent = deserializeAgentStatusSubject(parentKey)
const parsed = parseAgentChildWorkAliasInput({
parent,
provider,
segmentId,
kind,
aliasKind,
alias,
childWorkId: 'key-only',
fence: { invocationId: 'key-only', generation: 0 }
})
if (!parsed) {
return null
}
const identity = {
parent: parsed.parent,
provider: parsed.provider,
segmentId: parsed.segmentId,
kind: parsed.kind,
aliasKind: parsed.aliasKind,
alias: parsed.alias
}
return serializeAgentChildWorkAliasKey(identity) === value ? identity : null
}
export function agentChildWorkAliasesMatch(
left: AgentChildWorkAliasInput,
right: AgentChildWorkAliasInput
): boolean {
return (
serializeAgentChildWorkAliasKey(left) === serializeAgentChildWorkAliasKey(right) &&
left.childWorkId === right.childWorkId &&
agentChildWorkFencesEqual(left.fence, right.fence)
)
}
@@ -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
}
+273
View File
@@ -0,0 +1,273 @@
import {
AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX,
AGENT_CHILD_WORK_KINDS,
AGENT_CHILD_WORK_MEMBERSHIPS,
AGENT_CHILD_WORK_OUTCOMES,
AGENT_CHILD_WORK_STATES,
agentChildWorkFencesEqual,
type AgentChildWorkInput,
type AgentChildWorkInvocationFence,
type AgentChildWorkInvocationHistory,
type AgentChildWorkKind,
type AgentChildWorkMembership,
type AgentChildWorkOutcome,
type AgentChildWorkProviderTiming,
type AgentChildWorkProvenance,
type AgentChildWorkRecord,
type AgentChildWorkState
} from './agent-status-child-work'
import { parseAgentStatusSubject } from './agent-status-subject'
const MAX_ID_LENGTH = 256
const MAX_LABEL_LENGTH = 512
const MAX_DESCRIPTION_LENGTH = 8_000
const CHILD_WORK_KIND_SET: ReadonlySet<string> = new Set(AGENT_CHILD_WORK_KINDS)
const CHILD_WORK_STATE_SET: ReadonlySet<string> = new Set(AGENT_CHILD_WORK_STATES)
const CHILD_WORK_MEMBERSHIP_SET: ReadonlySet<string> = new Set(AGENT_CHILD_WORK_MEMBERSHIPS)
const CHILD_WORK_OUTCOME_SET: ReadonlySet<string> = new Set(AGENT_CHILD_WORK_OUTCOMES)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasOnlyKeys(
record: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = []
): boolean {
const keys = Object.keys(record)
return (
required.every((key) => Object.hasOwn(record, key)) &&
keys.every((key) => required.includes(key) || optional.includes(key))
)
}
function isBoundedString(value: unknown, maxLength = MAX_ID_LENGTH): value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > maxLength ||
value !== value.trim()
) {
return false
}
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x1f || code === 0x7f) {
return false
}
}
return true
}
function isTimestamp(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function isRevision(value: unknown): value is number {
return Number.isSafeInteger(value) && typeof value === 'number' && value >= 0
}
function isKind(value: unknown): value is AgentChildWorkKind {
return typeof value === 'string' && CHILD_WORK_KIND_SET.has(value)
}
function isState(value: unknown): value is AgentChildWorkState {
return typeof value === 'string' && CHILD_WORK_STATE_SET.has(value)
}
function isMembership(value: unknown): value is AgentChildWorkMembership {
return typeof value === 'string' && CHILD_WORK_MEMBERSHIP_SET.has(value)
}
function isOutcome(value: unknown): value is AgentChildWorkOutcome {
return typeof value === 'string' && CHILD_WORK_OUTCOME_SET.has(value)
}
export function parseAgentChildWorkInvocationFence(
value: unknown
): AgentChildWorkInvocationFence | null {
if (
!isRecord(value) ||
!hasOnlyKeys(value, ['invocationId', 'generation']) ||
!isBoundedString(value.invocationId) ||
!isRevision(value.generation)
) {
return null
}
return { invocationId: value.invocationId, generation: value.generation }
}
function parseProviderTiming(value: unknown): AgentChildWorkProviderTiming | null {
if (!isRecord(value) || !hasOnlyKeys(value, [], ['startedAt', 'completedAt'])) {
return null
}
if (
(value.startedAt !== undefined && !isTimestamp(value.startedAt)) ||
(value.completedAt !== undefined && !isTimestamp(value.completedAt))
) {
return null
}
return {
...(isTimestamp(value.startedAt) ? { startedAt: value.startedAt } : {}),
...(isTimestamp(value.completedAt) ? { completedAt: value.completedAt } : {})
}
}
function parseProvenance(value: unknown): AgentChildWorkProvenance | null {
if (
!isRecord(value) ||
!hasOnlyKeys(value, ['source', 'producerId']) ||
(value.source !== 'hook' &&
value.source !== 'structured-session' &&
value.source !== 'restore' &&
value.source !== 'transport') ||
!isBoundedString(value.producerId)
) {
return null
}
return { source: value.source, producerId: value.producerId }
}
function parseInvocationHistory(value: unknown): AgentChildWorkInvocationHistory[] | null {
if (!Array.isArray(value) || value.length > AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX) {
return null
}
const history: AgentChildWorkInvocationHistory[] = []
for (const candidate of value) {
if (
!isRecord(candidate) ||
!hasOnlyKeys(candidate, ['fence'], ['outcome', 'settledAt']) ||
(candidate.outcome !== undefined && !isOutcome(candidate.outcome)) ||
(candidate.settledAt !== undefined && !isTimestamp(candidate.settledAt))
) {
return null
}
const fence = parseAgentChildWorkInvocationFence(candidate.fence)
if (!fence) {
return null
}
history.push({
fence,
...(isOutcome(candidate.outcome) ? { outcome: candidate.outcome } : {}),
...(isTimestamp(candidate.settledAt) ? { settledAt: candidate.settledAt } : {})
})
}
return history
}
function parseOptionalLabel(value: unknown, maxLength = MAX_LABEL_LENGTH): string | null {
return value === undefined ? '' : isBoundedString(value, maxLength) ? value : null
}
export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | null {
if (
!isRecord(value) ||
!hasOnlyKeys(
value,
[
'childWorkId',
'parent',
'provider',
'kind',
'state',
'membership',
'firstObservedAt',
'observedAt',
'stoppable',
'invocation',
'provenance'
],
[
'outcome',
'name',
'description',
'agentType',
'model',
'totalTokens',
'providerTiming',
'previousInvocations'
]
) ||
!isBoundedString(value.childWorkId) ||
!isBoundedString(value.provider) ||
!isKind(value.kind) ||
!isState(value.state) ||
!isMembership(value.membership) ||
(value.outcome !== undefined && !isOutcome(value.outcome)) ||
(value.outcome !== undefined && value.membership !== 'settled') ||
!isTimestamp(value.firstObservedAt) ||
!isTimestamp(value.observedAt) ||
value.firstObservedAt > value.observedAt ||
typeof value.stoppable !== 'boolean' ||
(value.totalTokens !== undefined &&
(typeof value.totalTokens !== 'number' ||
!Number.isSafeInteger(value.totalTokens) ||
value.totalTokens < 0))
) {
return null
}
const parent = parseAgentStatusSubject(value.parent)
const invocation = parseAgentChildWorkInvocationFence(value.invocation)
const provenance = parseProvenance(value.provenance)
const timing =
value.providerTiming === undefined ? undefined : parseProviderTiming(value.providerTiming)
const history =
value.previousInvocations === undefined
? undefined
: parseInvocationHistory(value.previousInvocations)
const labels = {
name: parseOptionalLabel(value.name),
description: parseOptionalLabel(value.description, MAX_DESCRIPTION_LENGTH),
agentType: parseOptionalLabel(value.agentType),
model: parseOptionalLabel(value.model)
}
if (!parent || !invocation || !provenance || timing === null || history === null) {
return null
}
if (Object.values(labels).includes(null)) {
return null
}
const historyFenceKeys = history?.map(
(entry) => `${entry.fence.invocationId}\0${entry.fence.generation}`
)
if (
history &&
historyFenceKeys &&
(new Set(historyFenceKeys).size !== historyFenceKeys.length ||
history.some((entry) => agentChildWorkFencesEqual(entry.fence, invocation)))
) {
return null
}
return {
childWorkId: value.childWorkId,
parent,
provider: value.provider,
kind: value.kind,
state: value.state,
membership: value.membership,
...(isOutcome(value.outcome) ? { outcome: value.outcome } : {}),
...(labels.name ? { name: labels.name } : {}),
...(labels.description ? { description: labels.description } : {}),
...(labels.agentType ? { agentType: labels.agentType } : {}),
...(labels.model ? { model: labels.model } : {}),
...(typeof value.totalTokens === 'number' ? { totalTokens: value.totalTokens } : {}),
...(timing ? { providerTiming: timing } : {}),
firstObservedAt: value.firstObservedAt,
observedAt: value.observedAt,
stoppable: value.stoppable,
invocation,
...(history ? { previousInvocations: history } : {}),
provenance
}
}
export function parseAgentChildWorkRecord(value: unknown): AgentChildWorkRecord | null {
if (!isRecord(value) || !isRevision(value.revision)) {
return null
}
const input = { ...value }
delete input.revision
const parsed = parseAgentChildWorkInput(input)
return parsed ? { ...parsed, revision: value.revision } : null
}
@@ -0,0 +1,20 @@
import type { AgentChildWorkMembership, AgentChildWorkState } from './agent-status-child-work'
export type AgentChildWorkFreshnessInput = {
state: AgentChildWorkState
membership: AgentChildWorkMembership
parentEvidenceFresh: boolean
transportObservation: 'live' | 'unverifiable'
}
/** One decay rule for CLI and structured children; freshness never rewrites settled history. */
export function resolveAgentChildWorkFreshness(
input: AgentChildWorkFreshnessInput
): AgentChildWorkState {
if (input.membership === 'settled' || input.state === 'idle' || input.state === 'done') {
return input.state
}
return input.parentEvidenceFresh && input.transportObservation === 'live'
? input.state
: 'unverifiable'
}
@@ -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)
})
})
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshness'
import {
projectAgentChildWorkLegacyBackgroundTasks,
projectAgentChildWorkLegacySubagents,
type AgentChildWorkLegacyProjectionCandidate
} from './agent-status-child-work-projection'
import type { AgentChildWorkState } from './agent-status-child-work'
function candidate(
providerId: string,
overrides: Partial<AgentChildWorkLegacyProjectionCandidate['child']> = {}
): AgentChildWorkLegacyProjectionCandidate {
return {
providerId,
child: {
kind: 'agent',
state: 'working',
membership: 'live',
firstObservedAt: 123,
description: 'Investigate',
agentType: 'researcher',
model: 'model-a',
stoppable: true,
...overrides
}
}
}
describe('agent child-work legacy projection', () => {
it('preserves the bridge state mapping and stable host first-observation time', () => {
const states: (AgentChildWorkState | undefined)[] = [
undefined,
'working',
'monitoring',
'done',
'idle',
'waiting',
'blocked',
'unverifiable'
]
const projected = projectAgentChildWorkLegacySubagents(
states.map((state, index) => candidate(`task-${index}`, { state }))
)
expect(projected?.map((item) => item.state)).toEqual([
'working',
'working',
'working',
'idle',
'idle',
'waiting',
'blocked',
'unverifiable'
])
expect(projected?.every((item) => item.startedAt === 123)).toBe(true)
})
it('admits only agent kind and trims bounded nonempty provider ids', () => {
const projected = projectAgentChildWorkLegacySubagents([
candidate(' valid-id '),
candidate(''),
candidate(' '.repeat(10)),
candidate('x'.repeat(65)),
candidate('workflow-id', { kind: 'workflow' }),
candidate('command-id', { kind: 'command' }),
candidate('monitor-id', { kind: 'monitor' }),
candidate('unknown-id', { kind: 'unknown' })
])
expect(projected).toEqual([
{
id: 'valid-id',
state: 'working',
startedAt: 123,
agentType: 'researcher',
model: 'model-a',
description: 'Investigate'
}
])
})
it.each([31, 32, 33])('caps after accepting %i valid rows in source order', (count) => {
const interleaved = Array.from({ length: count }, (_, index) => [
candidate('', { kind: 'agent' }),
candidate(`task-${index}`)
]).flat()
const projected = projectAgentChildWorkLegacySubagents(interleaved)
expect(projected).toHaveLength(Math.min(count, AGENT_STATUS_MAX_SUBAGENTS))
expect(projected?.at(-1)?.id).toBe(`task-${Math.min(count, AGENT_STATUS_MAX_SUBAGENTS) - 1}`)
})
it('rejects missing host first-observation time instead of inventing zero', () => {
expect(
projectAgentChildWorkLegacySubagents([
candidate('task-invalid', { firstObservedAt: Number.NaN }),
candidate('task-valid', { firstObservedAt: 55 })
])
).toMatchObject([{ id: 'task-valid', startedAt: 55 }])
})
it('projects every kind into separate live and settled background lists', () => {
const projection = projectAgentChildWorkLegacyBackgroundTasks([
candidate('agent-live', { kind: 'agent', totalTokens: 10 }),
candidate('workflow-settled', {
kind: 'workflow',
state: 'done',
membership: 'settled'
}),
candidate('command-live', { kind: 'command' }),
candidate('monitor-live', { kind: 'monitor', state: 'monitoring' }),
candidate('unknown-settled', { kind: 'unknown', state: 'idle', membership: 'settled' })
])
expect(projection.tasks?.map((item) => item.kind)).toEqual(['agent', 'command', 'monitor'])
expect(projection.settledTasks?.map((item) => item.kind)).toEqual(['workflow', 'unknown'])
expect(projection.tasks?.[0]).toMatchObject({
id: 'agent-live',
startedAt: 123,
totalTokens: 10,
stoppable: true
})
})
})
describe('resolveAgentChildWorkFreshness', () => {
it.each([
[true, 'live', 'working'],
[false, 'live', 'unverifiable'],
[true, 'unverifiable', 'unverifiable'],
[false, 'unverifiable', 'unverifiable']
] as const)(
'maps parentFresh=%s transport=%s to %s for live work',
(parentEvidenceFresh, transportObservation, expected) => {
expect(
resolveAgentChildWorkFreshness({
state: 'working',
membership: 'live',
parentEvidenceFresh,
transportObservation
})
).toBe(expected)
}
)
it('preserves idle evidence and settled history on contact loss', () => {
expect(
resolveAgentChildWorkFreshness({
state: 'idle',
membership: 'live',
parentEvidenceFresh: false,
transportObservation: 'live'
})
).toBe('idle')
expect(
resolveAgentChildWorkFreshness({
state: 'done',
membership: 'settled',
parentEvidenceFresh: false,
transportObservation: 'unverifiable'
})
).toBe('done')
})
})
@@ -0,0 +1,146 @@
import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire'
import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types'
import type {
AgentChildWorkKind,
AgentChildWorkMembership,
AgentChildWorkState
} from './agent-status-child-work'
const LEGACY_PROVIDER_ID_MAX_LENGTH = 64
const BACKGROUND_PROVIDER_ID_MAX_LENGTH = 512
export type AgentChildWorkLegacyProjectionCandidate = {
providerId: string
child: {
kind: AgentChildWorkKind
state?: AgentChildWorkState
membership: AgentChildWorkMembership
firstObservedAt: number
name?: string
description?: string
agentType?: string
model?: string
totalTokens?: number
stoppable: boolean
}
}
function legacyProviderId(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 && trimmed.length <= LEGACY_PROVIDER_ID_MAX_LENGTH ? trimmed : null
}
function backgroundProviderId(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 && trimmed.length <= BACKGROUND_PROVIDER_ID_MAX_LENGTH ? trimmed : null
}
function legacySubagentState(
state: AgentChildWorkState | undefined
): AgentSubagentSnapshot['state'] | null {
if (state === undefined || state === 'working' || state === 'monitoring') {
return 'working'
}
if (state === 'done' || state === 'idle') {
return 'idle'
}
if (state === 'waiting' || state === 'blocked' || state === 'unverifiable') {
return state
}
return null
}
export function projectAgentChildWorkLegacySubagents(
candidates: readonly AgentChildWorkLegacyProjectionCandidate[]
): AgentSubagentSnapshot[] | undefined {
const projected: AgentSubagentSnapshot[] = []
for (const candidate of candidates) {
if (candidate.child.kind !== 'agent') {
continue
}
const id = legacyProviderId(candidate.providerId)
const state = legacySubagentState(candidate.child.state)
if (
!id ||
!state ||
!Number.isFinite(candidate.child.firstObservedAt) ||
candidate.child.firstObservedAt < 0
) {
continue
}
projected.push({
id,
state,
startedAt: candidate.child.firstObservedAt,
...(candidate.child.agentType !== undefined ? { agentType: candidate.child.agentType } : {}),
...(candidate.child.model !== undefined ? { model: candidate.child.model } : {}),
...(candidate.child.description !== undefined
? { description: candidate.child.description }
: {})
})
if (projected.length === AGENT_STATUS_MAX_SUBAGENTS) {
break
}
}
return projected.length > 0 ? projected : undefined
}
export type AgentChildWorkLegacyBackgroundProjection = {
tasks?: AgentSessionBackgroundTask[]
settledTasks?: AgentSessionBackgroundTask[]
}
function projectBackgroundTask(
candidate: AgentChildWorkLegacyProjectionCandidate
): AgentSessionBackgroundTask | null {
const id = backgroundProviderId(candidate.providerId)
if (
!id ||
!Number.isFinite(candidate.child.firstObservedAt) ||
candidate.child.firstObservedAt < 0
) {
return null
}
return {
id,
kind: candidate.child.kind,
...(candidate.child.description !== undefined
? { description: candidate.child.description }
: {}),
...(candidate.child.name !== undefined ? { name: candidate.child.name } : {}),
...(candidate.child.state !== undefined ? { state: candidate.child.state } : {}),
startedAt: candidate.child.firstObservedAt,
...(candidate.child.totalTokens !== undefined
? { totalTokens: candidate.child.totalTokens }
: {}),
stoppable: candidate.child.stoppable
}
}
export function projectAgentChildWorkLegacyBackgroundTasks(
candidates: readonly AgentChildWorkLegacyProjectionCandidate[]
): AgentChildWorkLegacyBackgroundProjection {
const tasks: AgentSessionBackgroundTask[] = []
const settledTasks: AgentSessionBackgroundTask[] = []
for (const candidate of candidates) {
const projected = projectBackgroundTask(candidate)
if (!projected) {
continue
}
if (candidate.child.membership === 'live') {
tasks.push(projected)
} else {
settledTasks.push(projected)
}
}
return {
...(tasks.length > 0 ? { tasks } : {}),
...(settledTasks.length > 0 ? { settledTasks } : {})
}
}
@@ -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')
}
@@ -0,0 +1,22 @@
import { agentChildWorkFencesEqual, type AgentChildWorkRecord } from './agent-status-child-work'
import type { AgentChildWorkStopRequest } from './agent-status-child-work-admission'
import { findAgentChildWork } from './agent-status-child-work-admission-core'
import type { AgentStatusStore } from './agent-status-store'
import { agentStatusSubjectsEqual } from './agent-status-subject'
export function authorizeAgentChildWorkStop(
store: AgentStatusStore,
request: AgentChildWorkStopRequest
): AgentChildWorkRecord | null {
const child = findAgentChildWork(store, request.childWorkId)
if (
!child ||
!agentStatusSubjectsEqual(child.parent, request.parent) ||
!agentChildWorkFencesEqual(child.invocation, request.expectedFence) ||
child.membership !== 'live' ||
child.stoppable !== true
) {
return null
}
return child
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_CHILD_WORK_KINDS,
AGENT_CHILD_WORK_STATES,
type AgentChildWorkInput
} from './agent-status-child-work'
import {
parseAgentChildWorkInput,
parseAgentChildWorkRecord
} from './agent-status-child-work-codec'
import { createAgentStatusStore } from './agent-status-store'
import { makeStructuredAgentStatusSubject } from './agent-status-subject'
const parent = makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'folder-1',
workspaceKind: 'folder'
},
'session_11111111-1111-4111-8111-111111111111'
)
function child(overrides: Partial<AgentChildWorkInput> = {}): AgentChildWorkInput {
return {
childWorkId: 'child-1',
parent,
provider: 'claude',
kind: 'agent',
state: 'working',
membership: 'live',
name: 'Research',
description: 'Investigate the contract',
agentType: 'researcher',
model: 'model-a',
totalTokens: 42,
providerTiming: { startedAt: 5, completedAt: 8 },
firstObservedAt: 10,
observedAt: 20,
stoppable: true,
invocation: { invocationId: 'invocation-1', generation: 1 },
previousInvocations: [
{
fence: { invocationId: 'invocation-0', generation: 0 },
outcome: 'cancelled',
settledAt: 9
}
],
provenance: { source: 'structured-session', producerId: 'journal-1' },
...overrides
}
}
describe('AgentChildWorkRecord', () => {
it('round-trips the complete canonical vocabulary through the store snapshot', () => {
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull()
const children = AGENT_CHILD_WORK_STATES.map((state, index) =>
child({
childWorkId: `child-${index}`,
kind: AGENT_CHILD_WORK_KINDS[index % AGENT_CHILD_WORK_KINDS.length],
state,
membership: state === 'done' ? 'settled' : 'live',
...(state === 'done' ? { outcome: 'failed' } : {}),
invocation: { invocationId: `invocation-${index}`, generation: index },
previousInvocations: undefined
})
)
expect(store.applyMutation({ children })).not.toBeNull()
const snapshot = store.getSnapshot()
expect(snapshot.children.map((item) => item.kind)).toEqual([
'agent',
'workflow',
'command',
'monitor',
'unknown',
'agent',
'workflow'
])
expect(snapshot.children.map((item) => item.state)).toEqual(AGENT_CHILD_WORK_STATES)
expect(snapshot.children.find((item) => item.state === 'done')).toMatchObject({
membership: 'settled',
outcome: 'failed',
totalTokens: 42,
providerTiming: { startedAt: 5, completedAt: 8 },
firstObservedAt: 10
})
})
it('parses a copied record with outcome, tokens, timing and bounded invocation history', () => {
const parsed = parseAgentChildWorkRecord({ ...child(), revision: 7 })
expect(parsed).toEqual({ ...child(), revision: 7 })
expect(parsed).not.toBe(child())
expect(parsed?.parent).not.toBe(parent)
})
it.each([
child({ childWorkId: 'bad\nid' }),
child({ provider: 'claude\0forged' }),
child({ firstObservedAt: 21 }),
child({ totalTokens: -1 }),
child({ membership: 'live', outcome: 'succeeded' }),
child({
previousInvocations: [
{ fence: { invocationId: 'invocation-1', generation: 1 }, outcome: 'failed' }
]
}),
child({
previousInvocations: [
{ fence: { invocationId: 'old', generation: 1 } },
{ fence: { invocationId: 'old', generation: 1 } }
]
})
])('rejects malformed canonical child input %#', (value) => {
expect(parseAgentChildWorkInput(value)).toBeNull()
})
})
+88
View File
@@ -0,0 +1,88 @@
import { agentStatusSubjectsEqual, type AgentStatusSubject } from './agent-status-subject'
export const AGENT_CHILD_WORK_KINDS = [
'agent',
'workflow',
'command',
'monitor',
'unknown'
] as const
export const AGENT_CHILD_WORK_STATES = [
'working',
'monitoring',
'waiting',
'blocked',
'done',
'idle',
'unverifiable'
] as const
export const AGENT_CHILD_WORK_MEMBERSHIPS = ['live', 'settled'] as const
export const AGENT_CHILD_WORK_OUTCOMES = ['succeeded', 'failed', 'cancelled', 'unknown'] as const
export const AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX = 32
export type AgentChildWorkId = string
export type AgentChildWorkKind = (typeof AGENT_CHILD_WORK_KINDS)[number]
export type AgentChildWorkState = (typeof AGENT_CHILD_WORK_STATES)[number]
export type AgentChildWorkMembership = (typeof AGENT_CHILD_WORK_MEMBERSHIPS)[number]
export type AgentChildWorkOutcome = (typeof AGENT_CHILD_WORK_OUTCOMES)[number]
export type AgentChildWorkInvocationFence = {
invocationId: string
generation: number
}
export type AgentChildWorkInvocationHistory = {
fence: AgentChildWorkInvocationFence
outcome?: AgentChildWorkOutcome
settledAt?: number
}
export type AgentChildWorkProviderTiming = {
startedAt?: number
completedAt?: number
}
export type AgentChildWorkProvenance = {
source: 'hook' | 'structured-session' | 'restore' | 'transport'
producerId: string
}
export type AgentChildWorkInput = {
childWorkId: AgentChildWorkId
parent: AgentStatusSubject
provider: string
kind: AgentChildWorkKind
state: AgentChildWorkState
membership: AgentChildWorkMembership
outcome?: AgentChildWorkOutcome
name?: string
description?: string
agentType?: string
model?: string
totalTokens?: number
providerTiming?: AgentChildWorkProviderTiming
firstObservedAt: number
observedAt: number
stoppable: boolean
invocation: AgentChildWorkInvocationFence
previousInvocations?: AgentChildWorkInvocationHistory[]
provenance: AgentChildWorkProvenance
}
export type AgentChildWorkRecord = AgentChildWorkInput & {
revision: number
}
export function agentChildWorkFencesEqual(
left: AgentChildWorkInvocationFence,
right: AgentChildWorkInvocationFence
): boolean {
return left.invocationId === right.invocationId && left.generation === right.generation
}
export function agentChildWorkBelongsTo(
child: Pick<AgentChildWorkInput, 'parent'>,
parent: AgentStatusSubject
): boolean {
return agentStatusSubjectsEqual(child.parent, parent)
}
+18 -5
View File
@@ -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)
})
})
+14 -5
View File
@@ -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())
}
}
},
@@ -9,6 +9,7 @@ import {
type AgentStatusRunAliasIndex
} from './agent-status-run-alias-index'
import type { AgentStatusExecutionScope } from './agent-status-subject'
import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract'
function scope(overrides: Partial<AgentStatusExecutionScope> = {}): AgentStatusExecutionScope {
return {
@@ -73,6 +74,19 @@ describe('agent status provider alias index', () => {
expect(decoded?.get(aliasKey)).toEqual(new Set(['run-a', 'run-b']))
})
it('round-trips every run allowed by the canonical parent-store limit for one alias', () => {
const aliasKey = serializeAgentStatusProviderAliasKey(alias())
const runIds = new Set(
Array.from({ length: AGENT_STATUS_STORE_LIMITS.parents }, (_, index) => `run-${index}`)
)
const decoded = deserializeAgentStatusRunAliasIndex(
serializeAgentStatusRunAliasIndex(new Map([[aliasKey, runIds]]))
)
expect(decoded?.get(aliasKey)).toEqual(runIds)
})
it('serializes deterministically regardless of insertion order', () => {
const firstKey = serializeAgentStatusProviderAliasKey(alias({ providerId: 'provider-a' }))
const secondKey = serializeAgentStatusProviderAliasKey(alias({ providerId: 'provider-b' }))
+2 -1
View File
@@ -8,12 +8,13 @@ import {
parseAgentStatusExecutionScope,
type AgentStatusExecutionScope
} from './agent-status-subject'
import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
import { measureUtf8ByteLength } from './utf8-byte-limits'
const PROVIDER_ALIAS_KEY_PREFIX = 'agent-status-provider-alias-v1:'
const MAX_ALIAS_INDEX_ENTRIES = 4096
const MAX_RUN_IDS_PER_ALIAS = 256
const MAX_RUN_IDS_PER_ALIAS = AGENT_STATUS_STORE_LIMITS.parents
const MAX_ALIAS_INDEX_RUN_REFERENCES = 16_384
const MAX_ALIAS_INDEX_SERIALIZED_BYTES = 4 * 1024 * 1024
const ALIAS_INDEX_JSON_STRUCTURE_LIMITS = {
@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentChildWorkAliasInput } from './agent-status-child-work-alias'
import type { AgentChildWorkInput } from './agent-status-child-work'
import { serializeAgentStatusRunAliasIndex } from './agent-status-run-alias-index'
import { createAgentStatusStore } from './agent-status-store'
import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract'
import { applyAgentStatusStoreMutation } from './agent-status-store-mutation'
import { agentStatusStoreStateFromSnapshot } from './agent-status-store-state'
import {
makePtyRunAgentStatusSubject,
makeStructuredAgentStatusSubject,
type AgentStatusExecutionScope
} from './agent-status-subject'
const scope: AgentStatusExecutionScope = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
}
function child(
parent: ReturnType<typeof makeStructuredAgentStatusSubject>,
childIndex: number,
description = 'x'.repeat(8_000)
) {
return {
childWorkId: `child-${childIndex}`,
parent,
provider: 'claude',
kind: 'agent',
state: 'working',
membership: 'live',
firstObservedAt: 1,
observedAt: 1,
stoppable: true,
invocation: { invocationId: `invocation-${childIndex}`, generation: 1 },
provenance: { source: 'structured-session', producerId: 'journal-1' },
description
} satisfies AgentChildWorkInput
}
function children(parent: ReturnType<typeof makeStructuredAgentStatusSubject>, offset: number) {
return Array.from({ length: AGENT_STATUS_STORE_LIMITS.mutationEntries / 2 }, (_, index) =>
child(parent, offset + index)
)
}
describe('AgentStatusStore bounds', () => {
it('rejects a mutation that would make the aggregate snapshot exceed its byte budget', () => {
const parent = makeStructuredAgentStatusSubject(scope, 'session-1')
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull()
expect(store.applyMutation({ children: children(parent, 0) })).not.toBeNull()
expect(store.applyMutation({ children: children(parent, 1_024) })).toBeNull()
expect(store.getSnapshot().children).toHaveLength(1_024)
})
it('serializes a derived provider-alias Set above the former 256-run ceiling', () => {
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
for (let index = 0; index < 257; index += 1) {
const runId = `run-${index}`
expect(
store.applyMutation({
parent: {
subject: makePtyRunAgentStatusSubject(scope, runId),
run: {
runId,
paneKey: `pane-${index}`,
attachment: { executionId: `execution-${index}` },
attribution: 'token',
providerSessions: [
{
provider: 'claude',
sessionKeyKind: 'session_id',
providerId: 'shared-provider-id'
}
],
role: 'root',
verdict: 'live'
}
}
})
).not.toBeNull()
}
expect(() => serializeAgentStatusRunAliasIndex(store.getRunAliasIndex())).not.toThrow()
})
it('reuses immutable record byte measurements when one child changes', () => {
const parent = makeStructuredAgentStatusSubject(scope, 'session-1')
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const first = child(parent, 0, 'first-description')
const unchanged = child(parent, 1, 'unchanged-description')
expect(
store.applyMutation({ parent: { subject: parent }, children: [first, unchanged] })
).not.toBeNull()
const stringify = vi.spyOn(JSON, 'stringify')
try {
expect(
store.applyMutation({ children: [{ ...first, observedAt: first.observedAt + 1 }] })
).not.toBeNull()
const serializedValues = stringify.mock.results
.map((result) => result.value)
.filter((value): value is string => typeof value === 'string')
expect(serializedValues.some((value) => value.includes('unchanged-description'))).toBe(false)
} finally {
stringify.mockRestore()
}
})
it('removes aliases for a child batch with one alias-map pass', () => {
const parent = makeStructuredAgentStatusSubject(scope, 'session-1')
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const childRecords = Array.from({ length: 4 }, (_, index) => child(parent, index, 'brief'))
const aliases: AgentChildWorkAliasInput[] = childRecords.map((record, index) => ({
parent,
provider: record.provider,
segmentId: `segment-${index}`,
kind: record.kind,
aliasKind: 'task_id',
alias: `task-${index}`,
childWorkId: record.childWorkId,
fence: record.invocation
}))
expect(
store.applyMutation({ parent: { subject: parent }, children: childRecords, aliases })
).not.toBeNull()
const state = agentStatusStoreStateFromSnapshot(store.getSnapshot(), 'epoch-a')
expect(state).not.toBeNull()
if (!state) {
throw new Error('Expected a valid store state')
}
let childWorkIdReads = 0
for (const [key, record] of state.aliases) {
const measured = { ...record }
Object.defineProperty(measured, 'childWorkId', {
enumerable: true,
get: () => {
childWorkIdReads += 1
return record.childWorkId
}
})
state.aliases.set(key, measured)
}
childWorkIdReads = 0
const next = applyAgentStatusStoreMutation(
state,
{ removeChildren: childRecords.map((record) => record.childWorkId) },
state.revision + 1
)
expect(next).not.toBeNull()
expect(childWorkIdReads).toBe(aliases.length)
})
})
@@ -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,65 @@
import type { AgentChildWorkAliasRecord } from './agent-status-child-work-alias'
import type { AgentChildWorkRecord } from './agent-status-child-work'
import {
AGENT_STATUS_STORE_LIMITS,
AGENT_STATUS_STORE_SNAPSHOT_VERSION,
type AgentStatusFactRecord,
type AgentStatusStoreSnapshot,
type AgentStatusTombstoneRecord
} from './agent-status-store-contract'
import type { AgentStatusParentRecord } from './agent-status-store-parent'
import type { AgentStatusStoreState } from './agent-status-store-state'
import { measureUtf8ByteLength } from './utf8-byte-limits'
/** Either the snapshot header or a single owner/record measured while accumulating the budget. */
type AgentStatusStoreByteBudgetRecord =
| AgentStatusStoreSnapshot
| AgentStatusParentRecord
| AgentChildWorkRecord
| AgentChildWorkAliasRecord
| AgentStatusFactRecord
| AgentStatusTombstoneRecord
const recordBytes = new WeakMap<AgentStatusStoreByteBudgetRecord, number>()
function serializedBytes(record: AgentStatusStoreByteBudgetRecord): 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
}
+284
View File
@@ -0,0 +1,284 @@
import {
parseAgentChildWorkAliasInput,
parseAgentChildWorkAliasRecord
} from './agent-status-child-work-alias'
import {
parseAgentChildWorkInput,
parseAgentChildWorkRecord
} from './agent-status-child-work-codec'
import {
AGENT_STATUS_STORE_LIMITS,
AGENT_STATUS_STORE_SNAPSHOT_VERSION,
type AgentStatusStoreMutation,
type AgentStatusStoreSnapshot,
type AgentStatusTombstoneEntity,
type AgentStatusTombstoneInput,
type AgentStatusTombstoneRecord
} from './agent-status-store-contract'
import {
parseAgentStatusFactIdentity,
parseAgentStatusFactInput,
parseAgentStatusFactRecord
} from './agent-status-store-fact-codec'
import {
parseAgentStatusParentInput,
parseAgentStatusParentRecord
} from './agent-status-store-parent'
import { parseAgentStatusSubject } from './agent-status-subject'
import { measureUtf8ByteLength } from './utf8-byte-limits'
const MAX_EPOCH_LENGTH = 256
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)
}
function hasOnlyKeys(
record: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = []
): boolean {
const keys = Object.keys(record)
return (
required.every((key) => Object.hasOwn(record, key)) &&
keys.every((key) => required.includes(key) || optional.includes(key))
)
}
function isRevision(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function isWithinSerializedLimit(value: unknown): boolean {
try {
return !measureUtf8ByteLength(JSON.stringify(value), {
stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes
}).exceededLimit
} catch {
return false
}
}
function isBoundedKey(value: unknown, maxLength: number): value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > maxLength ||
value !== value.trim()
) {
return false
}
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x1f || code === 0x7f) {
return false
}
}
return true
}
export function isAgentStatusStoreEpoch(value: unknown): value is string {
return isBoundedKey(value, MAX_EPOCH_LENGTH)
}
function isTombstoneEntity(value: unknown): value is AgentStatusTombstoneEntity {
return value === 'parent' || value === 'child' || value === 'alias' || value === 'fact'
}
export function parseAgentStatusTombstoneInput(value: unknown): AgentStatusTombstoneInput | null {
if (
!isRecord(value) ||
!hasOnlyKeys(value, ['entity', 'key']) ||
!isTombstoneEntity(value.entity) ||
!isBoundedKey(value.key, MAX_TOMBSTONE_KEY_LENGTH)
) {
return null
}
return { entity: value.entity, key: value.key }
}
export function parseAgentStatusTombstoneRecord(value: unknown): AgentStatusTombstoneRecord | null {
if (!isRecord(value) || !isRevision(value.revision)) {
return null
}
const input = { ...value }
delete input.revision
const tombstone = parseAgentStatusTombstoneInput(input)
return tombstone ? { ...tombstone, revision: value.revision } : null
}
function parseArray<T>(
value: unknown,
parser: (candidate: unknown) => T | null,
maxLength: number
): T[] | null {
if (!Array.isArray(value) || value.length > maxLength) {
return null
}
const parsed: T[] = []
for (const candidate of value) {
const item = parser(candidate)
if (!item) {
return null
}
parsed.push(item)
}
return parsed
}
function parseMutationArray<T>(
value: unknown,
parser: (candidate: unknown) => T | null
): T[] | null {
return parseArray(value, parser, AGENT_STATUS_STORE_LIMITS.mutationEntries)
}
function parseStringArray(value: unknown): string[] | null {
return parseMutationArray(value, (candidate) =>
isBoundedKey(candidate, MAX_TOMBSTONE_KEY_LENGTH) ? candidate : null
)
}
export function parseAgentStatusStoreMutation(value: unknown): AgentStatusStoreMutation | null {
const optionalKeys = [
'parent',
'removeParent',
'children',
'removeChildren',
'aliases',
'removeAliases',
'facts',
'removeFacts',
'tombstones'
]
if (
!isWithinSerializedLimit(value) ||
!isRecord(value) ||
!hasOnlyKeys(value, [], optionalKeys) ||
Object.keys(value).length === 0
) {
return null
}
const parent = value.parent === undefined ? undefined : parseAgentStatusParentInput(value.parent)
const removeParent =
value.removeParent === undefined ? undefined : parseAgentStatusSubject(value.removeParent)
const children =
value.children === undefined
? undefined
: parseMutationArray(value.children, parseAgentChildWorkInput)
const removeChildren =
value.removeChildren === undefined ? undefined : parseStringArray(value.removeChildren)
const aliases =
value.aliases === undefined
? undefined
: parseMutationArray(value.aliases, parseAgentChildWorkAliasInput)
const removeAliases =
value.removeAliases === undefined ? undefined : parseStringArray(value.removeAliases)
const facts =
value.facts === undefined
? undefined
: parseMutationArray(value.facts, parseAgentStatusFactInput)
const removeFacts =
value.removeFacts === undefined
? undefined
: parseMutationArray(value.removeFacts, parseAgentStatusFactIdentity)
const tombstones =
value.tombstones === undefined
? undefined
: parseMutationArray(value.tombstones, parseAgentStatusTombstoneInput)
const parsedValues = [
parent,
removeParent,
children,
removeChildren,
aliases,
removeAliases,
facts,
removeFacts,
tombstones
]
const sourceValues = optionalKeys.map((key) => value[key])
if (sourceValues.some((item, index) => item !== undefined && !parsedValues[index])) {
return null
}
const mutationEntryCount = [
children,
removeChildren,
aliases,
removeAliases,
facts,
removeFacts,
tombstones
].reduce((sum, items) => sum + (items?.length ?? 0), 0)
if (mutationEntryCount > AGENT_STATUS_STORE_LIMITS.mutationEntries) {
return null
}
return {
...(parent ? { parent } : {}),
...(removeParent ? { removeParent } : {}),
...(children ? { children } : {}),
...(removeChildren ? { removeChildren } : {}),
...(aliases ? { aliases } : {}),
...(removeAliases ? { removeAliases } : {}),
...(facts ? { facts } : {}),
...(removeFacts ? { removeFacts } : {}),
...(tombstones ? { tombstones } : {})
}
}
export function parseAgentStatusStoreSnapshot(value: unknown): AgentStatusStoreSnapshot | null {
const keys = [
'version',
'epoch',
'revision',
'parents',
'children',
'aliases',
'facts',
'tombstones'
]
if (
!isWithinSerializedLimit(value) ||
!isRecord(value) ||
!hasOnlyKeys(value, keys) ||
value.version !== AGENT_STATUS_STORE_SNAPSHOT_VERSION ||
!isAgentStatusStoreEpoch(value.epoch) ||
!isRevision(value.revision)
) {
return null
}
const parents = parseArray(
value.parents,
parseAgentStatusParentRecord,
AGENT_STATUS_STORE_LIMITS.parents
)
const children = parseArray(
value.children,
parseAgentChildWorkRecord,
AGENT_STATUS_STORE_LIMITS.children
)
const aliases = parseArray(
value.aliases,
parseAgentChildWorkAliasRecord,
AGENT_STATUS_STORE_LIMITS.aliases
)
const facts = parseArray(value.facts, parseAgentStatusFactRecord, AGENT_STATUS_STORE_LIMITS.facts)
const tombstones = parseArray(
value.tombstones,
parseAgentStatusTombstoneRecord,
AGENT_STATUS_STORE_LIMITS.tombstones
)
return parents && children && aliases && facts && tombstones
? {
version: AGENT_STATUS_STORE_SNAPSHOT_VERSION,
epoch: value.epoch,
revision: value.revision,
parents,
children,
aliases,
facts,
tombstones
}
: null
}
+62
View File
@@ -0,0 +1,62 @@
import type {
AgentChildWorkAliasInput,
AgentChildWorkAliasRecord
} from './agent-status-child-work-alias'
import type { AgentChildWorkInput, AgentChildWorkRecord } from './agent-status-child-work'
import type { AgentStatusParentInput, AgentStatusParentRecord } from './agent-status-store-parent'
import type { AgentStatusSubject } from './agent-status-subject'
export const AGENT_STATUS_STORE_SNAPSHOT_VERSION = 1 as const
export const AGENT_STATUS_STORE_LIMITS = {
parents: 2_048,
children: 8_192,
aliases: 16_384,
facts: 16_384,
tombstones: 1_024,
mutationEntries: 2_048,
serializedBytes: 16 * 1024 * 1024
} as const
export const AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS = 4_096
export type AgentStatusFactValue = string | number | boolean | null
export type AgentStatusFactInput = {
subject: AgentStatusSubject
key: string
value: AgentStatusFactValue
}
export type AgentStatusFactRecord = AgentStatusFactInput & { revision: number }
export type AgentStatusFactIdentity = Pick<AgentStatusFactInput, 'subject' | 'key'>
export type AgentStatusTombstoneEntity = 'parent' | 'child' | 'alias' | 'fact'
export type AgentStatusTombstoneInput = {
entity: AgentStatusTombstoneEntity
key: string
}
export type AgentStatusTombstoneRecord = AgentStatusTombstoneInput & { revision: number }
export type AgentStatusStoreMutation = {
parent?: AgentStatusParentInput
removeParent?: AgentStatusSubject
children?: AgentChildWorkInput[]
removeChildren?: string[]
aliases?: AgentChildWorkAliasInput[]
removeAliases?: string[]
facts?: AgentStatusFactInput[]
removeFacts?: AgentStatusFactIdentity[]
tombstones?: AgentStatusTombstoneInput[]
}
export type AgentStatusStoreSnapshot = {
version: typeof AGENT_STATUS_STORE_SNAPSHOT_VERSION
epoch: string
revision: number
parents: AgentStatusParentRecord[]
children: AgentChildWorkRecord[]
aliases: AgentChildWorkAliasRecord[]
facts: AgentStatusFactRecord[]
tombstones: AgentStatusTombstoneRecord[]
}
+119
View File
@@ -0,0 +1,119 @@
import type {
AgentStatusFactIdentity,
AgentStatusFactInput,
AgentStatusFactRecord,
AgentStatusFactValue
} from './agent-status-store-contract'
import {
deserializeAgentStatusSubject,
parseAgentStatusSubject,
serializeAgentStatusSubject
} from './agent-status-subject'
const MAX_FACT_KEY_LENGTH = 256
const MAX_FACT_STRING_LENGTH = 4_096
const FACT_KEY_PREFIX = 'agent-status-fact-v1:'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasExactKeys(record: Record<string, unknown>, expected: readonly string[]): boolean {
const keys = Object.keys(record)
return keys.length === expected.length && keys.every((key) => expected.includes(key))
}
function isRevision(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function isFactKey(value: unknown): value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > MAX_FACT_KEY_LENGTH ||
value !== value.trim()
) {
return false
}
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x1f || code === 0x7f) {
return false
}
}
return true
}
function parseFactValue(value: unknown): AgentStatusFactValue | undefined {
if (value === null || typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
return value.length <= MAX_FACT_STRING_LENGTH ? value : undefined
}
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
export function parseAgentStatusFactInput(value: unknown): AgentStatusFactInput | null {
if (!isRecord(value) || !hasExactKeys(value, ['subject', 'key', 'value'])) {
return null
}
const subject = parseAgentStatusSubject(value.subject)
const factValue = parseFactValue(value.value)
if (!subject || !isFactKey(value.key) || factValue === undefined) {
return null
}
return { subject, key: value.key, value: factValue }
}
export function parseAgentStatusFactRecord(value: unknown): AgentStatusFactRecord | null {
if (!isRecord(value) || !isRevision(value.revision)) {
return null
}
const input = { ...value }
delete input.revision
const fact = parseAgentStatusFactInput(input)
return fact ? { ...fact, revision: value.revision } : null
}
export function parseAgentStatusFactIdentity(value: unknown): AgentStatusFactIdentity | null {
if (!isRecord(value) || !hasExactKeys(value, ['subject', 'key'])) {
return null
}
const subject = parseAgentStatusSubject(value.subject)
return subject && isFactKey(value.key) ? { subject, key: value.key } : null
}
export function serializeAgentStatusFactKey(fact: AgentStatusFactIdentity): string {
const parsed = parseAgentStatusFactIdentity({ subject: fact.subject, key: fact.key })
if (!parsed) {
throw new Error('Invalid agent status fact identity')
}
return `${FACT_KEY_PREFIX}${JSON.stringify([
serializeAgentStatusSubject(parsed.subject),
parsed.key
])}`
}
export function deserializeAgentStatusFactKey(value: string): AgentStatusFactIdentity | null {
if (!value.startsWith(FACT_KEY_PREFIX)) {
return null
}
let tuple: unknown
try {
tuple = JSON.parse(value.slice(FACT_KEY_PREFIX.length))
} catch {
return null
}
if (!Array.isArray(tuple) || tuple.length !== 2) {
return null
}
const [subjectKey, key] = tuple
if (typeof subjectKey !== 'string') {
return null
}
const subject = deserializeAgentStatusSubject(subjectKey)
const parsed = parseAgentStatusFactIdentity({ subject, key })
return parsed && serializeAgentStatusFactKey(parsed) === value ? parsed : null
}
+263
View File
@@ -0,0 +1,263 @@
import { parseAgentChildWorkAliasRecord } from './agent-status-child-work-alias'
import {
deserializeAgentChildWorkBindingKey,
serializeAgentChildWorkBindingKey
} from './agent-status-child-work-binding'
import { parseAgentChildWorkRecord } from './agent-status-child-work-codec'
import type {
AgentStatusStoreMutation,
AgentStatusTombstoneEntity
} from './agent-status-store-contract'
import {
AGENT_STATUS_STORE_LIMITS,
AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS
} from './agent-status-store-contract'
import { parseAgentStatusFactRecord } from './agent-status-store-fact-codec'
import { parseAgentStatusParentRecord } from './agent-status-store-parent'
import {
agentStatusFactMapKey,
agentStatusTombstoneMapKey,
cloneAgentStatusStoreState,
deepFreezeAgentStatusStoreValue,
validateAgentStatusStoreState,
type AgentStatusStoreState
} from './agent-status-store-state'
import {
agentStatusSubjectsEqual,
deserializeAgentStatusSubject,
serializeAgentStatusSubject,
type AgentStatusSubject
} from './agent-status-subject'
function addTombstone(
state: AgentStatusStoreState,
entity: AgentStatusTombstoneEntity,
key: string,
revision: number
): void {
const record = deepFreezeAgentStatusStoreValue({ entity, key, revision })
const mapKey = agentStatusTombstoneMapKey(entity, key)
state.tombstones.delete(mapKey)
state.tombstones.set(mapKey, record)
}
function compactTombstones(state: AgentStatusStoreState): void {
for (const [key, tombstone] of state.tombstones) {
if (
state.tombstones.size <= AGENT_STATUS_STORE_LIMITS.tombstones &&
state.revision - tombstone.revision < AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS
) {
break
}
state.tombstones.delete(key)
}
}
function removeAlias(state: AgentStatusStoreState, key: string, revision: number): void {
state.aliases.delete(key)
addTombstone(state, 'alias', key, revision)
}
function removeFact(state: AgentStatusStoreState, key: string, revision: number): void {
state.facts.delete(key)
addTombstone(state, 'fact', key, revision)
}
function removeChild(
state: AgentStatusStoreState,
childWorkId: string,
revision: number,
removedChildWorkIds: Set<string>
): void {
state.children.delete(childWorkId)
addTombstone(state, 'child', childWorkId, revision)
removedChildWorkIds.add(childWorkId)
}
function removeAliasesForChildren(
state: AgentStatusStoreState,
removedChildWorkIds: ReadonlySet<string>,
revision: number
): void {
for (const [key, alias] of state.aliases) {
if (removedChildWorkIds.has(alias.childWorkId)) {
removeAlias(state, key, revision)
}
}
}
function removeParent(
state: AgentStatusStoreState,
subject: AgentStatusSubject,
revision: number,
removedChildWorkIds: Set<string>
): void {
const key = serializeAgentStatusSubject(subject)
state.parents.delete(key)
addTombstone(state, 'parent', key, revision)
for (const child of state.children.values()) {
if (agentStatusSubjectsEqual(child.parent, subject)) {
removeChild(state, child.childWorkId, revision, removedChildWorkIds)
}
}
for (const [factMapKey, fact] of state.facts) {
if (agentStatusSubjectsEqual(fact.subject, subject)) {
removeFact(state, factMapKey, revision)
}
}
}
function applyExplicitTombstone(
state: AgentStatusStoreState,
tombstone: { entity: AgentStatusTombstoneEntity; key: string },
revision: number,
removedChildWorkIds: Set<string>
): boolean {
if (tombstone.entity === 'parent') {
const subject = deserializeAgentStatusSubject(tombstone.key)
if (!subject) {
return false
}
removeParent(state, subject, revision, removedChildWorkIds)
return true
}
if (tombstone.entity === 'child') {
removeChild(state, tombstone.key, revision, removedChildWorkIds)
} else if (tombstone.entity === 'alias') {
if (!deserializeAgentChildWorkBindingKey(tombstone.key)) {
return false
}
removeAlias(state, tombstone.key, revision)
} else {
removeFact(state, tombstone.key, revision)
}
addTombstone(state, tombstone.entity, tombstone.key, revision)
return true
}
function upsertParent(
state: AgentStatusStoreState,
input: NonNullable<AgentStatusStoreMutation['parent']>,
revision: number
): boolean {
const key = serializeAgentStatusSubject(input.subject)
const tombstone = state.tombstones.get(agentStatusTombstoneMapKey('parent', key))
if (tombstone && tombstone.revision >= revision) {
return false
}
const previous = state.parents.get(key)
const record = parseAgentStatusParentRecord({
...input,
...(input.firstObservedAt === undefined && previous?.firstObservedAt !== undefined
? { firstObservedAt: previous.firstObservedAt }
: {}),
revision
})
if (!record) {
return false
}
state.parents.set(key, deepFreezeAgentStatusStoreValue(record))
return true
}
function upsertChildren(
state: AgentStatusStoreState,
children: NonNullable<AgentStatusStoreMutation['children']>,
revision: number
): boolean {
for (const input of children) {
const previous = state.children.get(input.childWorkId)
if (
state.tombstones.has(agentStatusTombstoneMapKey('child', input.childWorkId)) ||
!state.parents.has(serializeAgentStatusSubject(input.parent)) ||
(previous !== undefined && previous.firstObservedAt !== input.firstObservedAt) ||
(previous !== undefined && input.observedAt < previous.observedAt)
) {
return false
}
const record = parseAgentChildWorkRecord({ ...input, revision })
if (!record) {
return false
}
state.children.set(input.childWorkId, deepFreezeAgentStatusStoreValue(record))
}
return true
}
function upsertAliases(
state: AgentStatusStoreState,
aliases: NonNullable<AgentStatusStoreMutation['aliases']>,
revision: number
): boolean {
for (const input of aliases) {
const record = parseAgentChildWorkAliasRecord({ ...input, revision })
if (!record) {
return false
}
state.aliases.set(
serializeAgentChildWorkBindingKey(record),
deepFreezeAgentStatusStoreValue(record)
)
}
return true
}
function upsertFacts(
state: AgentStatusStoreState,
facts: NonNullable<AgentStatusStoreMutation['facts']>,
revision: number
): boolean {
for (const input of facts) {
const record = parseAgentStatusFactRecord({ ...input, revision })
if (!record || !state.parents.has(serializeAgentStatusSubject(record.subject))) {
return false
}
state.facts.set(agentStatusFactMapKey(record), deepFreezeAgentStatusStoreValue(record))
}
return true
}
export function applyAgentStatusStoreMutation(
current: AgentStatusStoreState,
mutation: AgentStatusStoreMutation,
revision: number
): AgentStatusStoreState | null {
const next = cloneAgentStatusStoreState(current)
next.revision = revision
const removedChildWorkIds = new Set<string>()
if (mutation.removeParent) {
removeParent(next, mutation.removeParent, revision, removedChildWorkIds)
}
for (const childWorkId of mutation.removeChildren ?? []) {
removeChild(next, childWorkId, revision, removedChildWorkIds)
}
for (const key of mutation.removeAliases ?? []) {
if (!deserializeAgentChildWorkBindingKey(key)) {
return null
}
removeAlias(next, key, revision)
}
for (const identity of mutation.removeFacts ?? []) {
removeFact(next, agentStatusFactMapKey(identity), revision)
}
for (const tombstone of mutation.tombstones ?? []) {
if (!applyExplicitTombstone(next, tombstone, revision, removedChildWorkIds)) {
return null
}
}
removeAliasesForChildren(next, removedChildWorkIds, revision)
if (mutation.parent && !upsertParent(next, mutation.parent, revision)) {
return null
}
if (mutation.children && !upsertChildren(next, mutation.children, revision)) {
return null
}
if (mutation.aliases && !upsertAliases(next, mutation.aliases, revision)) {
return null
}
if (mutation.facts && !upsertFacts(next, mutation.facts, revision)) {
return null
}
compactTombstones(next)
return validateAgentStatusStoreState(next) ? next : null
}
+117
View File
@@ -0,0 +1,117 @@
import type { AgentStatusIpcPayload } from './agent-status-ipc-payload'
import { parseAgentStatusPtyRunRecord, type AgentStatusPtyRunRecord } from './agent-status-run'
import { parseAgentStatusIpcPayloadCopy } from './agent-status-store-status-codec'
import { parseAgentStatusSubject, type AgentStatusSubject } from './agent-status-subject'
export type AgentStatusParentInput = {
subject: AgentStatusSubject
status?: AgentStatusIpcPayload
run?: AgentStatusPtyRunRecord
firstObservedAt?: number
}
export type AgentStatusParentRecord = AgentStatusParentInput & { revision: number }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasOnlyKeys(
record: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = []
): boolean {
const keys = Object.keys(record)
return (
required.every((key) => Object.hasOwn(record, key)) &&
keys.every((key) => required.includes(key) || optional.includes(key))
)
}
function isTimestamp(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function isRevision(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function isParentScopeConsistent(parent: AgentStatusParentInput): boolean {
const { subject, status, run } = parent
if (run && (subject.kind !== 'pty-run' || run.runId !== subject.runId)) {
return false
}
if (
subject.kind !== 'pty-run' &&
(status?.runId !== undefined || status?.executionId !== undefined)
) {
return false
}
if (status?.worktreeId !== undefined && status.worktreeId !== subject.workspaceId) {
return false
}
if (subject.kind === 'pty' && status?.paneKey !== subject.paneKey) {
return false
}
if (subject.kind === 'pty-run' && status?.runId !== undefined && status.runId !== subject.runId) {
return false
}
if (run && status?.paneKey !== undefined && status.paneKey !== run.paneKey) {
return false
}
if (
run &&
status?.executionId !== undefined &&
status.executionId !== run.attachment.executionId
) {
return false
}
if (
run &&
status?.providerAlias &&
run.providerSessions.length > 0 &&
!run.providerSessions.some(
(session) =>
session.provider === status.providerAlias?.provider &&
session.sessionKeyKind === status.providerAlias.sessionKeyKind &&
session.providerId === status.providerAlias.providerId
)
) {
return false
}
return true
}
export function parseAgentStatusParentInput(value: unknown): AgentStatusParentInput | null {
if (
!isRecord(value) ||
!hasOnlyKeys(value, ['subject'], ['status', 'run', 'firstObservedAt']) ||
(value.firstObservedAt !== undefined && !isTimestamp(value.firstObservedAt))
) {
return null
}
const subject = parseAgentStatusSubject(value.subject)
const status =
value.status === undefined ? undefined : parseAgentStatusIpcPayloadCopy(value.status)
const run = value.run === undefined ? undefined : parseAgentStatusPtyRunRecord(value.run)
if (!subject || (value.status !== undefined && !status) || (value.run !== undefined && !run)) {
return null
}
const parent: AgentStatusParentInput = {
subject,
...(status ? { status } : {}),
...(run ? { run } : {}),
...(isTimestamp(value.firstObservedAt) ? { firstObservedAt: value.firstObservedAt } : {})
}
return isParentScopeConsistent(parent) ? parent : null
}
export function parseAgentStatusParentRecord(value: unknown): AgentStatusParentRecord | null {
if (!isRecord(value) || !isRevision(value.revision)) {
return null
}
const input = { ...value }
delete input.revision
const parent = parseAgentStatusParentInput(input)
return parent ? { ...parent, revision: value.revision } : null
}
@@ -0,0 +1,45 @@
import {
AGENT_STATUS_STORE_LIMITS,
type AgentStatusStoreSnapshot
} from './agent-status-store-contract'
import { parseAgentStatusStoreSnapshot } from './agent-status-store-codec'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
import { measureUtf8ByteLength } from './utf8-byte-limits'
const SNAPSHOT_STRUCTURE_LIMITS = {
structuralTokens: AGENT_STATUS_STORE_LIMITS.serializedBytes,
nestingDepth: 32
} as const
function isWithinByteLimit(value: string): boolean {
return !measureUtf8ByteLength(value, {
stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes
}).exceededLimit
}
/** Bounded persistence form; callers write the returned string with their existing owner. */
export function serializeAgentStatusStoreSnapshot(snapshot: AgentStatusStoreSnapshot): string {
const parsed = parseAgentStatusStoreSnapshot(snapshot)
if (!parsed) {
throw new Error('Invalid agent status store snapshot')
}
const serialized = JSON.stringify(parsed)
if (!isWithinByteLimit(serialized)) {
throw new Error('Agent status store snapshot exceeds its serialized-byte limit')
}
return serialized
}
export function deserializeAgentStatusStoreSnapshot(
serialized: string
): AgentStatusStoreSnapshot | null {
if (!isWithinByteLimit(serialized)) {
return null
}
try {
assertJsonTextStructureWithinLimits(serialized, SNAPSHOT_STRUCTURE_LIMITS)
return parseAgentStatusStoreSnapshot(JSON.parse(serialized))
} catch {
return null
}
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { createAgentStatusStore } from './agent-status-store'
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 refuses a replay whose revision pair is spent', () => {
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)
expect(replica.getParent(subject)).toBeNull()
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.getParent(subject)?.firstObservedAt).toBe(30)
// Outcome only, deliberately: a spent replay is refused and a resequenced one is not. Which
// layer refuses it is NOT asserted, because no test at this API can tell — transport
// consecutiveness, the parent-revision validator and the tombstone guard each refuse it alone,
// and ablating any two leaves this green. Attributing one of them here would be a false claim.
expect(replica.applyTransportEnvelope(oldPublication)).toBe(false)
expect(replica.getParent(subject)?.firstObservedAt).toBe(30)
const resequenced = owner.applyMutation({ parent: { subject, firstObservedAt: 10 } })
expect(resequenced).not.toBeNull()
expect(replica.applyTransportEnvelope(resequenced)).toBe(true)
expect(replica.getParent(subject)?.firstObservedAt).toBe(10)
expect(replica.getSnapshot()).toEqual(owner.getSnapshot())
})
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()
})
})
@@ -0,0 +1,31 @@
import {
serializeAgentStatusProviderAliasKey,
type AgentStatusRunAliasIndex
} from './agent-status-run-alias-index'
import type { AgentStatusParentRecord } from './agent-status-store-parent'
export function deriveAgentStatusStoreRunAliasIndex(
parents: Iterable<AgentStatusParentRecord>
): AgentStatusRunAliasIndex {
const index: AgentStatusRunAliasIndex = new Map()
for (const parent of parents) {
if (parent.subject.kind !== 'pty-run' || !parent.run) {
continue
}
for (const session of parent.run.providerSessions) {
const key = serializeAgentStatusProviderAliasKey({
executionHostId: parent.subject.executionHostId,
wslDistro: parent.subject.wslDistro,
workspaceId: parent.subject.workspaceId,
workspaceKind: parent.subject.workspaceKind,
provider: session.provider,
sessionKeyKind: session.sessionKeyKind,
providerId: session.providerId
})
const runIds = index.get(key) ?? new Set<string>()
runIds.add(parent.run.runId)
index.set(key, runIds)
}
}
return index
}
+266
View File
@@ -0,0 +1,266 @@
import {
parseAgentChildWorkAliasRecord,
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,
AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS,
type AgentStatusFactIdentity,
type AgentStatusFactRecord,
type AgentStatusStoreSnapshot,
type AgentStatusTombstoneEntity,
type AgentStatusTombstoneRecord
} from './agent-status-store-contract'
import {
parseAgentStatusStoreSnapshot,
parseAgentStatusTombstoneRecord
} from './agent-status-store-codec'
import {
deserializeAgentStatusFactKey,
parseAgentStatusFactRecord,
serializeAgentStatusFactKey
} from './agent-status-store-fact-codec'
import {
parseAgentStatusParentRecord,
type AgentStatusParentRecord
} from './agent-status-store-parent'
import { deserializeAgentStatusSubject, serializeAgentStatusSubject } from './agent-status-subject'
export type AgentStatusStoreState = {
epoch: string
revision: number
parents: Map<string, AgentStatusParentRecord>
children: Map<string, AgentChildWorkRecord>
aliases: Map<string, AgentChildWorkAliasRecord>
facts: Map<string, AgentStatusFactRecord>
tombstones: Map<string, AgentStatusTombstoneRecord>
}
export function deepFreezeAgentStatusStoreValue<T>(value: T): T {
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) {
return value
}
for (const nested of Object.values(value)) {
deepFreezeAgentStatusStoreValue(nested)
}
return Object.freeze(value)
}
export function agentStatusFactMapKey(fact: AgentStatusFactIdentity): string {
return serializeAgentStatusFactKey(fact)
}
export function agentStatusTombstoneMapKey(
entity: AgentStatusTombstoneEntity,
key: string
): string {
return `${entity}\0${key}`
}
export function createEmptyAgentStatusStoreState(epoch: string): AgentStatusStoreState {
return {
epoch,
revision: 0,
parents: new Map(),
children: new Map(),
aliases: new Map(),
facts: new Map(),
tombstones: new Map()
}
}
export function cloneAgentStatusStoreState(state: AgentStatusStoreState): AgentStatusStoreState {
return {
epoch: state.epoch,
revision: state.revision,
parents: new Map(state.parents),
children: new Map(state.children),
aliases: new Map(state.aliases),
facts: new Map(state.facts),
tombstones: new Map(state.tombstones)
}
}
function snapshotCandidateFromAgentStatusStoreState(state: AgentStatusStoreState) {
return {
version: AGENT_STATUS_STORE_SNAPSHOT_VERSION,
epoch: state.epoch,
revision: state.revision,
parents: [...state.parents.values()],
children: [...state.children.values()],
aliases: [...state.aliases.values()],
facts: [...state.facts.values()],
tombstones: [...state.tombstones.values()]
}
}
function hasMatchingFence(child: AgentChildWorkRecord, alias: AgentChildWorkAliasRecord): boolean {
if (agentChildWorkFencesEqual(child.invocation, alias.fence)) {
return true
}
return (
child.previousInvocations?.some((entry) =>
agentChildWorkFencesEqual(entry.fence, alias.fence)
) === true
)
}
export function validateAgentStatusStoreState(state: AgentStatusStoreState): boolean {
if (
state.parents.size > AGENT_STATUS_STORE_LIMITS.parents ||
state.children.size > AGENT_STATUS_STORE_LIMITS.children ||
state.aliases.size > AGENT_STATUS_STORE_LIMITS.aliases ||
state.facts.size > AGENT_STATUS_STORE_LIMITS.facts ||
state.tombstones.size > AGENT_STATUS_STORE_LIMITS.tombstones
) {
return false
}
for (const [key, parent] of state.parents) {
if (
key !== serializeAgentStatusSubject(parent.subject) ||
parent.revision > state.revision ||
(state.tombstones.get(agentStatusTombstoneMapKey('parent', key))?.revision ?? -1) >=
parent.revision
) {
return false
}
}
for (const [childWorkId, child] of state.children) {
if (
childWorkId !== child.childWorkId ||
child.revision > state.revision ||
!state.parents.has(serializeAgentStatusSubject(child.parent)) ||
state.tombstones.has(agentStatusTombstoneMapKey('child', childWorkId))
) {
return false
}
}
for (const [key, alias] of state.aliases) {
const child = state.children.get(alias.childWorkId)
const tombstone = state.tombstones.get(agentStatusTombstoneMapKey('alias', key))
if (
key !== serializeAgentChildWorkBindingKey(alias) ||
alias.revision > state.revision ||
!child ||
!agentChildWorkBelongsTo(child, alias.parent) ||
child.provider !== alias.provider ||
child.kind !== alias.kind ||
!hasMatchingFence(child, alias) ||
(tombstone !== undefined && tombstone.revision >= alias.revision)
) {
return false
}
}
for (const [key, fact] of state.facts) {
const tombstone = state.tombstones.get(agentStatusTombstoneMapKey('fact', key))
if (
key !== agentStatusFactMapKey(fact) ||
fact.revision > state.revision ||
!state.parents.has(serializeAgentStatusSubject(fact.subject)) ||
(tombstone !== undefined && tombstone.revision >= fact.revision)
) {
return false
}
}
for (const item of state.tombstones.values()) {
if (
item.revision > state.revision ||
(item.entity === 'parent' && !deserializeAgentStatusSubject(item.key)) ||
(item.entity === 'alias' && !deserializeAgentChildWorkBindingKey(item.key)) ||
(item.entity === 'fact' && !deserializeAgentStatusFactKey(item.key))
) {
return false
}
}
return agentStatusStoreFitsByteBudget(state)
}
export function snapshotFromAgentStatusStoreState(
state: AgentStatusStoreState
): AgentStatusStoreSnapshot {
const snapshot = parseAgentStatusStoreSnapshot(snapshotCandidateFromAgentStatusStoreState(state))
if (!snapshot) {
throw new Error('Agent status store produced an invalid snapshot')
}
return deepFreezeAgentStatusStoreValue(snapshot)
}
export function agentStatusStoreStateFromSnapshot(
snapshot: AgentStatusStoreSnapshot,
epoch: string
): AgentStatusStoreState | null {
const state = createEmptyAgentStatusStoreState(epoch)
state.revision = snapshot.revision
for (const parent of snapshot.parents) {
const record = parseAgentStatusParentRecord(parent)
if (!record) {
return null
}
const key = serializeAgentStatusSubject(record.subject)
if (state.parents.has(key)) {
return null
}
state.parents.set(key, deepFreezeAgentStatusStoreValue(record))
}
for (const child of snapshot.children) {
const record = parseAgentChildWorkRecord(child)
if (!record || state.children.has(record.childWorkId)) {
return null
}
state.children.set(record.childWorkId, deepFreezeAgentStatusStoreValue(record))
}
for (const alias of snapshot.aliases) {
const record = parseAgentChildWorkAliasRecord(alias)
if (!record) {
return null
}
const key = serializeAgentChildWorkBindingKey(record)
if (state.aliases.has(key)) {
return null
}
state.aliases.set(key, deepFreezeAgentStatusStoreValue(record))
}
for (const fact of snapshot.facts) {
const record = parseAgentStatusFactRecord(fact)
if (!record) {
return null
}
const key = agentStatusFactMapKey(record)
if (state.facts.has(key)) {
return null
}
state.facts.set(key, deepFreezeAgentStatusStoreValue(record))
}
for (const tombstone of [...snapshot.tombstones].sort(
(left, right) => left.revision - right.revision
)) {
const record = parseAgentStatusTombstoneRecord(tombstone)
if (!record) {
return null
}
const key = agentStatusTombstoneMapKey(record.entity, record.key)
if (state.tombstones.has(key)) {
return null
}
state.tombstones.set(key, deepFreezeAgentStatusStoreValue(record))
}
for (const [key, tombstone] of state.tombstones) {
if (state.revision - tombstone.revision < AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS) {
break
}
state.tombstones.delete(key)
}
return validateAgentStatusStoreState(state) ? state : null
}
@@ -0,0 +1,245 @@
import { normalizeAgentProviderSession } from './agent-session-resume'
import type { AgentStatusIpcPayload } from './agent-status-ipc-payload'
import {
isAgentStatusExecutionId,
isAgentStatusRunId,
parseAgentStatusProviderAlias
} from './agent-status-run'
import { normalizeAgentStatusPayload } from './agent-status-types'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
import { measureUtf8ByteLength } from './utf8-byte-limits'
const MAX_STATUS_BYTES = 256 * 1024
const MAX_ID_LENGTH = 4_096
const STATUS_STRUCTURE_LIMITS = { structuralTokens: 16_384, nestingDepth: 24 } as const
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasOnlyKeys(
record: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = []
): boolean {
const keys = Object.keys(record)
return (
required.every((key) => Object.hasOwn(record, key)) &&
keys.every((key) => required.includes(key) || optional.includes(key))
)
}
function isTimestamp(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function isRevision(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function isBoundedString(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_ID_LENGTH &&
!value.includes('\0')
)
}
function copyJsonRecord(value: unknown): Record<string, unknown> | null {
let serialized: string
try {
serialized = JSON.stringify(value)
} catch {
return null
}
if (measureUtf8ByteLength(serialized, { stopAfterBytes: MAX_STATUS_BYTES }).exceededLimit) {
return null
}
try {
assertJsonTextStructureWithinLimits(serialized, STATUS_STRUCTURE_LIMITS)
const parsed: unknown = JSON.parse(serialized)
return isRecord(parsed) ? parsed : null
} catch {
return null
}
}
function parseObservation(value: unknown): Record<string, unknown> | null {
if (
!isRecord(value) ||
!hasOnlyKeys(
value,
['origin', 'authorityId', 'incarnation', 'revision', 'observedAt'],
['boundary', 'kind']
) ||
(value.origin !== 'hook' &&
value.origin !== 'osc' &&
value.origin !== 'title' &&
value.origin !== 'process' &&
value.origin !== 'launch' &&
value.origin !== 'orchestration' &&
value.origin !== 'structured') ||
!isBoundedString(value.authorityId) ||
!isRevision(value.incarnation) ||
!isRevision(value.revision) ||
!isTimestamp(value.observedAt) ||
(value.boundary !== undefined && value.boundary !== true) ||
(value.kind !== undefined &&
value.kind !== 'transition' &&
value.kind !== 'snapshot' &&
value.kind !== 'identity-only')
) {
return null
}
return {
origin: value.origin,
authorityId: value.authorityId,
incarnation: value.incarnation,
revision: value.revision,
observedAt: value.observedAt,
...(value.boundary === true ? { boundary: true } : {}),
...(value.kind !== undefined ? { kind: value.kind } : {})
}
}
function parseOrchestration(value: unknown): Record<string, unknown> | null {
if (!isRecord(value) || !isBoundedString(value.taskId) || !isBoundedString(value.dispatchId)) {
return null
}
const optionalStrings = [
'taskTitle',
'displayName',
'parentTerminalHandle',
'parentPaneKey',
'coordinatorHandle',
'orchestrationRunId'
]
if (optionalStrings.some((key) => value[key] !== undefined && !isBoundedString(value[key]))) {
return null
}
if (
value.dispatchStatus !== undefined &&
value.dispatchStatus !== 'pending' &&
value.dispatchStatus !== 'dispatched' &&
value.dispatchStatus !== 'completed' &&
value.dispatchStatus !== 'failed' &&
value.dispatchStatus !== 'circuit_broken'
) {
return null
}
if (value.attention !== undefined && !isRecord(value.attention)) {
return null
}
return { ...value }
}
function copyOptionalString(
source: Record<string, unknown>,
target: Record<string, unknown>,
key: string
): boolean {
const value = source[key]
if (value === undefined) {
return true
}
if (!isBoundedString(value)) {
return false
}
target[key] = value
return true
}
export function parseAgentStatusIpcPayloadCopy(value: unknown): AgentStatusIpcPayload | null {
const copied = copyJsonRecord(value)
const payload = normalizeAgentStatusPayload(copied)
if (
!copied ||
!payload ||
!isBoundedString(copied.paneKey) ||
(copied.connectionId !== null && !isBoundedString(copied.connectionId)) ||
!isTimestamp(copied.receivedAt) ||
!isTimestamp(copied.stateStartedAt) ||
(copied.evidenceObservedAt !== undefined && !isTimestamp(copied.evidenceObservedAt))
) {
return null
}
const parsed: Record<string, unknown> = {
...payload,
paneKey: copied.paneKey,
connectionId: copied.connectionId,
receivedAt: copied.receivedAt,
stateStartedAt: copied.stateStartedAt
}
for (const key of [
'launchToken',
'terminalHandle',
'tabId',
'worktreeId',
'promptInteractionKey'
]) {
if (!copyOptionalString(copied, parsed, key)) {
return null
}
}
if (copied.runId !== undefined) {
if (!isAgentStatusRunId(copied.runId)) {
return null
}
parsed.runId = copied.runId
}
if (copied.executionId !== undefined) {
if (!isAgentStatusExecutionId(copied.executionId)) {
return null
}
parsed.executionId = copied.executionId
}
if (copied.providerAlias !== undefined) {
const providerAlias = parseAgentStatusProviderAlias(copied.providerAlias)
if (!providerAlias) {
return null
}
parsed.providerAlias = providerAlias
}
if (isTimestamp(copied.evidenceObservedAt)) {
parsed.evidenceObservedAt = copied.evidenceObservedAt
}
if (copied.providerSession !== undefined) {
const providerSession = normalizeAgentProviderSession(copied.providerSession)
if (!providerSession) {
return null
}
parsed.providerSession = providerSession
}
if (copied.orchestration !== undefined) {
const orchestration = parseOrchestration(copied.orchestration)
if (!orchestration) {
return null
}
parsed.orchestration = orchestration
}
if (copied.observation !== undefined) {
const observation = parseObservation(copied.observation)
if (!observation) {
return null
}
parsed.observation = observation
}
if (copied.providerSessionOnly === true) {
parsed.providerSessionOnly = true
} else if (copied.providerSessionOnly !== undefined && copied.providerSessionOnly !== false) {
return null
}
if (copied.restoredUnconfirmed === true) {
parsed.restoredUnconfirmed = true
} else if (copied.restoredUnconfirmed !== undefined && copied.restoredUnconfirmed !== false) {
return null
}
if (copied.structuredHost === 'held' || copied.structuredHost === 'owned') {
parsed.structuredHost = copied.structuredHost
} else if (copied.structuredHost !== undefined) {
return null
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Every required and optional field is rebuilt from its canonical parser above.
return parsed as AgentStatusIpcPayload
}
+436
View File
@@ -0,0 +1,436 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusIpcPayload } from './agent-status-ipc-payload'
import type { AgentChildWorkAliasInput } from './agent-status-child-work-alias'
import type { AgentChildWorkInput } from './agent-status-child-work'
import { serializeAgentStatusProviderAliasKey } from './agent-status-run-alias-index'
import { createAgentStatusStore } from './agent-status-store'
import {
AGENT_STATUS_STORE_LIMITS,
AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS,
type AgentStatusStoreSnapshot
} from './agent-status-store-contract'
import {
deserializeAgentStatusStoreSnapshot,
serializeAgentStatusStoreSnapshot
} from './agent-status-store-persistence'
import {
makePtyAgentStatusSubject,
makePtyRunAgentStatusSubject,
makeStructuredAgentStatusSubject,
serializeAgentStatusSubject,
type AgentStatusExecutionScope,
type AgentStatusSubject
} from './agent-status-subject'
const SESSION_ID = 'session_11111111-1111-4111-8111-111111111111'
function scope(overrides: Partial<AgentStatusExecutionScope> = {}): AgentStatusExecutionScope {
return {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree',
...overrides
}
}
function subject(overrides: Partial<AgentStatusExecutionScope> = {}): AgentStatusSubject {
return makeStructuredAgentStatusSubject(scope(overrides), SESSION_ID)
}
function status(parent: AgentStatusSubject, overrides: Partial<AgentStatusIpcPayload> = {}) {
return {
state: 'working',
prompt: 'Ship it',
paneKey: 'structured-pane-key',
connectionId: null,
receivedAt: 20,
evidenceObservedAt: 18,
stateStartedAt: 10,
worktreeId: parent.workspaceId,
structuredHost: 'owned',
...overrides
} satisfies AgentStatusIpcPayload
}
function child(parent: AgentStatusSubject, overrides: Partial<AgentChildWorkInput> = {}) {
return {
childWorkId: 'child-1',
parent,
provider: 'claude',
kind: 'agent',
state: 'working',
membership: 'live',
firstObservedAt: 12,
observedAt: 20,
stoppable: true,
invocation: { invocationId: 'invocation-1', generation: 1 },
provenance: { source: 'structured-session', producerId: 'journal-1' },
...overrides
} satisfies AgentChildWorkInput
}
function alias(parent: AgentStatusSubject): AgentChildWorkAliasInput {
return {
parent,
provider: 'claude',
segmentId: 'segment-1',
kind: 'agent',
aliasKind: 'task_id',
alias: 'provider-task-1',
childWorkId: 'child-1',
fence: { invocationId: 'invocation-1', generation: 1 }
}
}
function populatedStore() {
const parent = subject()
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const committed = store.applyMutation({
parent: { subject: parent, status: status(parent), firstObservedAt: 5 },
children: [child(parent)],
aliases: [alias(parent)],
facts: [{ subject: parent, key: 'acknowledged', value: true }]
})
expect(committed?.revision).toBe(1)
return { parent, store }
}
describe('AgentStatusStore', () => {
it('stores structured status on the parent record and isolates colliding scoped subjects', () => {
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const subjects = [
subject(),
subject({ wslDistro: 'Ubuntu' }),
subject({ executionHostId: 'ssh:host-a' }),
subject({ executionHostId: 'runtime:peer-a' }),
subject({ workspaceId: 'folder-1', workspaceKind: 'folder' })
]
for (const [index, scopedSubject] of subjects.entries()) {
expect(
store.applyMutation({
parent: {
subject: scopedSubject,
status: status(scopedSubject, { prompt: `prompt-${index}` }),
firstObservedAt: index + 1
}
})
).not.toBeNull()
}
expect(store.getSnapshot().parents).toHaveLength(subjects.length)
expect(subjects.map((item) => store.getParent(item)?.status?.prompt)).toEqual([
'prompt-0',
'prompt-1',
'prompt-2',
'prompt-3',
'prompt-4'
])
})
it('accepts trusted PTY fixtures while enforcing run and scope consistency', () => {
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const pty = makePtyAgentStatusSubject(scope(), 'pane-1')
const runSubject = makePtyRunAgentStatusSubject(scope(), 'run-1')
expect(
store.applyMutation({
parent: { subject: pty, status: status(pty, { paneKey: 'pane-1' }) }
})
).not.toBeNull()
expect(
store.applyMutation({
parent: { subject: pty, status: status(pty, { paneKey: 'wrong-pane' }) }
})
).toBeNull()
const run = {
runId: 'run-1',
paneKey: 'pane-2',
attachment: { executionId: 'execution-1' },
attribution: 'token',
providerSessions: [
{ provider: 'claude', sessionKeyKind: 'session_id', providerId: 'provider-1' }
],
role: 'root',
verdict: 'live'
} as const
expect(
store.applyMutation({
parent: {
subject: runSubject,
run,
status: status(runSubject, {
paneKey: 'pane-2',
runId: 'run-1',
executionId: 'execution-1',
providerAlias: {
provider: 'claude',
sessionKeyKind: 'session_id',
providerId: 'provider-1'
}
})
}
})
).not.toBeNull()
expect(store.getParent(runSubject)?.run).toEqual(run)
expect(
store.applyMutation({
parent: {
subject: runSubject,
status: status(runSubject, { paneKey: 'pane-2', runId: 'wrong-run' })
}
})
).toBeNull()
})
it('indexes all run owners of one scoped provider session and reconstructs the index on restore', () => {
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const providerAlias = {
...scope(),
provider: 'claude',
sessionKeyKind: 'session_id',
providerId: 'shared-session'
} as const
const aliasKey = serializeAgentStatusProviderAliasKey(providerAlias)
for (const runId of ['run-1', 'run-2']) {
const run = {
runId,
paneKey: `pane-${runId}`,
attachment: { executionId: `execution-${runId}` },
attribution: 'token',
providerSessions: [
{
provider: providerAlias.provider,
sessionKeyKind: providerAlias.sessionKeyKind,
providerId: providerAlias.providerId
}
],
role: 'root',
verdict: 'live'
} as const
expect(
store.applyMutation({
parent: { subject: makePtyRunAgentStatusSubject(scope(), runId), run }
})
).not.toBeNull()
}
expect(store.getRunAliasIndex().get(aliasKey)).toEqual(new Set(['run-1', 'run-2']))
const restored = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' })
expect(restored.applySnapshot(store.getSnapshot())).toBe(true)
expect(restored.getRunAliasIndex().get(aliasKey)).toEqual(new Set(['run-1', 'run-2']))
expect(
store.applyMutation({ removeParent: makePtyRunAgentStatusSubject(scope(), 'run-1') })
).not.toBeNull()
expect(store.getRunAliasIndex().get(aliasKey)).toEqual(new Set(['run-2']))
})
it('allows a removed structured session to be observed again at a later revision', () => {
const parent = subject()
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
expect(store.applyMutation({ parent: { subject: parent, firstObservedAt: 10 } })).not.toBeNull()
expect(store.applyMutation({ removeParent: parent })).not.toBeNull()
expect(store.applyMutation({ parent: { subject: parent, firstObservedAt: 30 } })).not.toBeNull()
expect(store.getParent(parent)?.firstObservedAt).toBe(30)
expect(store.getSnapshot().tombstones).toContainEqual({
entity: 'parent',
key: serializeAgentStatusSubject(parent),
revision: 2
})
})
it('bounds tombstones while revision envelopes reject stale replay after reacquisition and compaction', () => {
const parent = subject()
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const first = store.applyMutation({ parent: { subject: parent, firstObservedAt: 10 } })
expect(first).not.toBeNull()
const replica = createAgentStatusStore({ epoch: 'replica', mode: 'replica' })
expect(replica.applySnapshot(store.getSnapshot())).toBe(true)
const removal = store.applyMutation({ removeParent: parent })
expect(removal).not.toBeNull()
expect(replica.applyTransportEnvelope(removal)).toBe(true)
const reopened = store.applyMutation({ parent: { subject: parent, firstObservedAt: 30 } })
expect(reopened).not.toBeNull()
expect(replica.applyTransportEnvelope(reopened)).toBe(true)
expect(replica.applyTransportEnvelope(first)).toBe(false)
expect(replica.getParent(parent)?.firstObservedAt).toBe(30)
expect(
store.applyMutation({
removeChildren: Array.from(
{ length: AGENT_STATUS_STORE_LIMITS.tombstones + 1 },
(_, index) => `unused-child-${index}`
)
})
).not.toBeNull()
expect(store.getSnapshot().tombstones).toHaveLength(AGENT_STATUS_STORE_LIMITS.tombstones)
expect(store.getSnapshot().tombstones.some((item) => item.entity === 'parent')).toBe(false)
expect(replica.applySnapshot(store.getSnapshot())).toBe(true)
expect(replica.applyTransportEnvelope(first)).toBe(false)
expect(replica.getParent(parent)?.firstObservedAt).toBe(30)
})
it.fails('does not roll a replica back to a superseded epoch snapshot', () => {
const parent = subject()
const first = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
first.applyMutation({ parent: { subject: parent, firstObservedAt: 10 } })
const second = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' })
second.applyMutation({ parent: { subject: parent, firstObservedAt: 20 } })
const replica = createAgentStatusStore({ epoch: 'replica', mode: 'replica' })
expect(replica.applySnapshot(first.getSnapshot())).toBe(true)
expect(replica.applySnapshot(second.getSnapshot())).toBe(true)
expect(replica.applySnapshot(first.getSnapshot())).toBe(false)
expect(replica.getParent(parent)?.firstObservedAt).toBe(20)
})
it('expires tombstones from a restored snapshot after the retention revision window', () => {
const parent = subject()
const source = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
expect(source.applyMutation({ removeParent: parent })).not.toBeNull()
const snapshot = {
...source.getSnapshot(),
revision: AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS + 1
}
const restored = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' })
expect(restored.applySnapshot(snapshot)).toBe(true)
expect(restored.getSnapshot().tombstones).toEqual([])
})
it('uses parsed immutable copies instead of caller-owned objects', () => {
const parent = subject()
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const inputStatus = status(parent, {
subagents: [{ id: 'provider-child', state: 'working', startedAt: 1, description: 'before' }]
})
expect(store.applyMutation({ parent: { subject: parent, status: inputStatus } })).not.toBeNull()
inputStatus.prompt = 'mutated input'
inputStatus.subagents?.splice(0)
const first = store.getParent(parent)
expect(first?.status?.prompt).toBe('Ship it')
expect(first?.status?.subagents).toHaveLength(1)
expect(Object.isFrozen(first)).toBe(true)
expect(Object.isFrozen(first?.status?.subagents)).toBe(true)
expect(() => first?.status?.subagents?.splice(0)).toThrow()
expect(store.getParent(parent)?.status?.subagents).toHaveLength(1)
})
it('commits parent, child, alias, fact and tombstone state atomically', () => {
const { parent, store } = populatedStore()
const before = store.getSnapshot()
const invalidAlias = { ...alias(parent), provider: 'codex' }
expect(
store.applyMutation({
children: [child(parent, { state: 'waiting', observedAt: 21 })],
aliases: [invalidAlias],
facts: [{ subject: parent, key: 'unread', value: true }]
})
).toBeNull()
expect(store.getSnapshot()).toEqual(before)
const removal = store.applyMutation({ removeParent: parent })
const removed = store.getSnapshot()
expect(removal?.revision).toBe(2)
expect(removed.parents).toEqual([])
expect(removed.children).toEqual([])
expect(removed.aliases).toEqual([])
expect(removed.facts).toEqual([])
expect(new Set(removed.tombstones.map((item) => item.entity))).toEqual(
new Set(['parent', 'child', 'alias', 'fact'])
)
expect(new Set(removed.tombstones.map((item) => item.revision))).toEqual(new Set([2]))
})
it('never resurrects an exactly removed child id within tombstone retention', () => {
const { parent, store } = populatedStore()
expect(store.applyMutation({ removeChildren: ['child-1'] })).not.toBeNull()
expect(store.applyMutation({ children: [child(parent, { observedAt: 30 })] })).toBeNull()
expect(store.applyMutation({ removeParent: parent })).not.toBeNull()
})
it('persists a bounded snapshot and restores child identity under a new epoch', () => {
const { parent, store } = populatedStore()
expect(
store.applyMutation({ removeFacts: [{ subject: parent, key: 'acknowledged' }] })
).not.toBeNull()
expect(
store.applyMutation({ facts: [{ subject: parent, key: 'acknowledged', value: true }] })
).not.toBeNull()
const serialized = serializeAgentStatusStoreSnapshot(store.getSnapshot())
const persisted = deserializeAgentStatusStoreSnapshot(serialized)
const restarted = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' })
expect(persisted).not.toBeNull()
expect(restarted.applySnapshot(persisted)).toBe(true)
expect(restarted.getSnapshot().epoch).toBe('epoch-b')
expect(restarted.getSnapshot().revision).toBe(3)
expect(restarted.getChildren(parent)[0]?.childWorkId).toBe('child-1')
expect(restarted.getChildren(parent)[0]?.firstObservedAt).toBe(12)
})
it('fails closed before parsing oversized persistence payloads or allocating huge mutations', () => {
const parse = vi.spyOn(JSON, 'parse')
const oversized = ' '.repeat(AGENT_STATUS_STORE_LIMITS.serializedBytes + 1)
expect(deserializeAgentStatusStoreSnapshot(oversized)).toBeNull()
expect(parse).not.toHaveBeenCalled()
parse.mockRestore()
const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
expect(
store.applyMutation({
removeChildren: Array.from(
{ length: AGENT_STATUS_STORE_LIMITS.mutationEntries + 1 },
(_, index) => `child-${index}`
)
})
).toBeNull()
expect(store.getSnapshot().revision).toBe(0)
const scopedSubject = subject()
expect(
store.applyMutation({
parent: {
subject: scopedSubject,
status: {
...status(scopedSubject),
orchestration: {
taskId: 'task-1',
dispatchId: 'dispatch-1',
attention: { oversized: 'x'.repeat(300 * 1024) }
}
}
}
})
).toBeNull()
})
it('rejects malformed and scope-mismatched snapshots without changing state', () => {
const { store } = populatedStore()
const before = store.getSnapshot()
const malformed: AgentStatusStoreSnapshot = {
...before,
children: [
{
...before.children[0],
parent: subject({ workspaceId: 'other-workspace' })
}
]
}
expect(store.applySnapshot(malformed)).toBe(false)
expect(store.getSnapshot()).toEqual(before)
})
it('serializes subject removal keys exactly in tombstones', () => {
const { parent, store } = populatedStore()
expect(store.applyMutation({ removeParent: parent })).not.toBeNull()
expect(store.getSnapshot().tombstones).toContainEqual({
entity: 'parent',
key: serializeAgentStatusSubject(parent),
revision: 2
})
})
})
+189
View File
@@ -0,0 +1,189 @@
import { agentChildWorkBelongsTo, type AgentChildWorkRecord } from './agent-status-child-work'
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,
parseAgentStatusStoreMutation,
parseAgentStatusStoreSnapshot
} from './agent-status-store-codec'
import { applyAgentStatusStoreMutation } from './agent-status-store-mutation'
import type { AgentStatusRunAliasIndex } from './agent-status-run-alias-index'
import {
parseAgentStatusParentRecord,
type AgentStatusParentRecord
} from './agent-status-store-parent'
import {
agentStatusStoreStateFromSnapshot,
createEmptyAgentStatusStoreState,
deepFreezeAgentStatusStoreValue,
snapshotFromAgentStatusStoreState
} from './agent-status-store-state'
import { deriveAgentStatusStoreRunAliasIndex } from './agent-status-store-run-index'
import {
parseAgentStatusSubject,
serializeAgentStatusSubject,
type AgentStatusSubject
} from './agent-status-subject'
import {
parseAgentStatusTransportEnvelope,
type AgentStatusMutationEnvelope
} from './agent-status-transport-envelope'
export type AgentStatusStoreMode = 'authority' | 'replica'
export type AgentStatusStore = {
getParent(subject: AgentStatusSubject): AgentStatusParentRecord | null
getChildren(subject: AgentStatusSubject): AgentChildWorkRecord[]
getChild(childWorkId: string): AgentChildWorkRecord | null
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
applyTransportEnvelope(envelope: unknown): boolean
}
export type CreateAgentStatusStoreOptions = {
epoch: string
mode: AgentStatusStoreMode
}
export function createAgentStatusStore(options: CreateAgentStatusStoreOptions): AgentStatusStore {
if (!isAgentStatusStoreEpoch(options.epoch)) {
throw new Error('Invalid agent status store epoch')
}
let state = createEmptyAgentStatusStoreState(options.epoch)
let snapshotApplied = options.mode === 'authority'
const store: AgentStatusStore = {
resolveChildAliases(aliases) {
return resolveAgentStatusChildBindings(state, aliases)
},
getParent(subject) {
const parsed = parseAgentStatusSubject(subject)
if (!parsed) {
return null
}
const record = state.parents.get(serializeAgentStatusSubject(parsed))
return record ? deepFreezeAgentStatusStoreValue(parseAgentStatusParentRecord(record)) : null
},
getChildren(subject) {
const parsed = parseAgentStatusSubject(subject)
if (!parsed) {
return []
}
const children = [...state.children.values()]
.filter((child) => agentChildWorkBelongsTo(child, parsed))
.map((child) => parseAgentChildWorkRecord(child))
.filter((child): child is AgentChildWorkRecord => child !== null)
return deepFreezeAgentStatusStoreValue(children)
},
getChild(childWorkId) {
return state.children.get(childWorkId) ?? null
},
getAlias(identity) {
return state.aliases.get(serializeAgentChildWorkAliasKey(identity)) ?? null
},
getAliasesForChild(childWorkId) {
return deepFreezeAgentStatusStoreValue(
[...state.aliases.values()].filter((alias) => alias.childWorkId === childWorkId)
)
},
getRunAliasIndex() {
return deriveAgentStatusStoreRunAliasIndex(state.parents.values())
},
getSnapshot() {
return snapshotFromAgentStatusStoreState(state)
},
applyMutation(value) {
if (options.mode !== 'authority') {
return null
}
const mutation = parseAgentStatusStoreMutation(value)
if (!mutation || state.revision === Number.MAX_SAFE_INTEGER) {
return null
}
const previousRevision = state.revision
const next = applyAgentStatusStoreMutation(state, mutation, previousRevision + 1)
if (!next) {
return null
}
state = next
return deepFreezeAgentStatusStoreValue({
type: 'mutation',
epoch: state.epoch,
previousRevision,
revision: state.revision,
mutation
})
},
applySnapshot(value) {
const snapshot = parseAgentStatusStoreSnapshot(value)
if (!snapshot) {
return false
}
if (options.mode === 'authority') {
if (state.revision !== 0) {
return false
}
const restored = agentStatusStoreStateFromSnapshot(snapshot, options.epoch)
if (!restored) {
return false
}
state = restored
snapshotApplied = true
return true
}
if (
snapshotApplied &&
snapshot.epoch === state.epoch &&
snapshot.revision <= state.revision
) {
return false
}
const mirrored = agentStatusStoreStateFromSnapshot(snapshot, snapshot.epoch)
if (!mirrored) {
return false
}
state = mirrored
snapshotApplied = true
return true
},
applyTransportEnvelope(value) {
if (options.mode !== 'replica') {
return false
}
const envelope = parseAgentStatusTransportEnvelope(value)
if (!envelope) {
return false
}
if (envelope.type === 'snapshot') {
return store.applySnapshot(envelope.snapshot)
}
if (
!snapshotApplied ||
envelope.epoch !== state.epoch ||
envelope.previousRevision !== state.revision ||
envelope.revision !== state.revision + 1
) {
return false
}
const next = applyAgentStatusStoreMutation(state, envelope.mutation, envelope.revision)
if (!next) {
return false
}
state = next
return true
}
}
return store
}
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest'
import { createAgentStatusStore } from './agent-status-store'
import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract'
import { makeStructuredAgentStatusSubject } from './agent-status-subject'
import {
deserializeAgentStatusTransportEnvelope,
serializeAgentStatusTransportEnvelope
} from './agent-status-transport-envelope'
const parent = makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
'session_11111111-1111-4111-8111-111111111111'
)
describe('agent status transport envelope', () => {
it('requires a snapshot before replay and then accepts only contiguous same-epoch mutations', () => {
const authority = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const first = authority.applyMutation({ parent: { subject: parent, firstObservedAt: 10 } })
expect(first).not.toBeNull()
const replica = createAgentStatusStore({ epoch: 'replica-placeholder', mode: 'replica' })
expect(replica.applyTransportEnvelope(first)).toBe(false)
expect(
replica.applyTransportEnvelope({ type: 'snapshot', snapshot: authority.getSnapshot() })
).toBe(true)
const second = authority.applyMutation({
facts: [{ subject: parent, key: 'acknowledged', value: true }]
})
expect(replica.applyTransportEnvelope(second)).toBe(true)
expect(replica.getSnapshot()).toEqual(authority.getSnapshot())
expect(replica.applyTransportEnvelope(second)).toBe(false)
expect(
replica.applyTransportEnvelope({
...second,
previousRevision: 9,
revision: 10
})
).toBe(false)
})
it('adopts a restart snapshot before replay and rejects the predecessor epoch', () => {
const firstAuthority = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
firstAuthority.applyMutation({ parent: { subject: parent } })
const persisted = firstAuthority.getSnapshot()
const replica = createAgentStatusStore({ epoch: 'replica-placeholder', mode: 'replica' })
replica.applySnapshot(persisted)
const stale = firstAuthority.applyMutation({
facts: [{ subject: parent, key: 'unread', value: true }]
})
const restarted = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' })
expect(restarted.applySnapshot(persisted)).toBe(true)
const restartedSnapshot = restarted.getSnapshot()
expect(restartedSnapshot.epoch).toBe('epoch-b')
expect(replica.applySnapshot(restartedSnapshot)).toBe(true)
expect(replica.applyTransportEnvelope(stale)).toBe(false)
const fresh = restarted.applyMutation({
facts: [{ subject: parent, key: 'retained', value: true }]
})
expect(replica.applyTransportEnvelope(fresh)).toBe(true)
expect(replica.getSnapshot().facts.map((fact) => fact.key)).toEqual(['retained'])
})
it('round-trips a mutation envelope and fails closed on malformed or oversized input', () => {
const authority = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const mutation = authority.applyMutation({ parent: { subject: parent } })
expect(mutation).not.toBeNull()
if (!mutation) {
return
}
expect(
deserializeAgentStatusTransportEnvelope(serializeAgentStatusTransportEnvelope(mutation))
).toEqual(mutation)
expect(
deserializeAgentStatusTransportEnvelope(
JSON.stringify({ ...mutation, revision: mutation.revision + 2 })
)
).toBeNull()
const parse = vi.spyOn(JSON, 'parse')
expect(
deserializeAgentStatusTransportEnvelope(
' '.repeat(AGENT_STATUS_STORE_LIMITS.serializedBytes + 1)
)
).toBeNull()
expect(parse).not.toHaveBeenCalled()
parse.mockRestore()
})
it('does not resurrect an exact removal through stale replay', () => {
const authority = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' })
const insertion = authority.applyMutation({ parent: { subject: parent } })
const replica = createAgentStatusStore({ epoch: 'replica-placeholder', mode: 'replica' })
expect(replica.applySnapshot(authority.getSnapshot())).toBe(true)
const removal = authority.applyMutation({ removeParent: parent })
expect(replica.applyTransportEnvelope(removal)).toBe(true)
expect(replica.getParent(parent)).toBeNull()
expect(replica.applyTransportEnvelope(insertion)).toBe(false)
expect(replica.getParent(parent)).toBeNull()
})
})
@@ -0,0 +1,113 @@
import {
AGENT_STATUS_STORE_LIMITS,
type AgentStatusStoreMutation,
type AgentStatusStoreSnapshot
} from './agent-status-store-contract'
import {
isAgentStatusStoreEpoch,
parseAgentStatusStoreMutation,
parseAgentStatusStoreSnapshot
} from './agent-status-store-codec'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
import { measureUtf8ByteLength } from './utf8-byte-limits'
const ENVELOPE_STRUCTURE_LIMITS = {
structuralTokens: 512 * 1024,
nestingDepth: 40
} as const
export type AgentStatusSnapshotEnvelope = {
type: 'snapshot'
snapshot: AgentStatusStoreSnapshot
}
export type AgentStatusMutationEnvelope = {
type: 'mutation'
epoch: string
previousRevision: number
revision: number
mutation: AgentStatusStoreMutation
}
export type AgentStatusTransportEnvelope = AgentStatusSnapshotEnvelope | AgentStatusMutationEnvelope
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasExactKeys(record: Record<string, unknown>, expected: readonly string[]): boolean {
const keys = Object.keys(record)
return keys.length === expected.length && keys.every((key) => expected.includes(key))
}
function isRevision(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
export function parseAgentStatusTransportEnvelope(
value: unknown
): AgentStatusTransportEnvelope | null {
if (!isRecord(value)) {
return null
}
if (value.type === 'snapshot' && hasExactKeys(value, ['type', 'snapshot'])) {
const snapshot = parseAgentStatusStoreSnapshot(value.snapshot)
return snapshot ? { type: 'snapshot', snapshot } : null
}
if (
value.type !== 'mutation' ||
!hasExactKeys(value, ['type', 'epoch', 'previousRevision', 'revision', 'mutation']) ||
!isAgentStatusStoreEpoch(value.epoch) ||
!isRevision(value.previousRevision) ||
!isRevision(value.revision) ||
value.revision !== value.previousRevision + 1
) {
return null
}
const mutation = parseAgentStatusStoreMutation(value.mutation)
return mutation
? {
type: 'mutation',
epoch: value.epoch,
previousRevision: value.previousRevision,
revision: value.revision,
mutation
}
: null
}
export function serializeAgentStatusTransportEnvelope(
envelope: AgentStatusTransportEnvelope
): string {
const parsed = parseAgentStatusTransportEnvelope(envelope)
if (!parsed) {
throw new Error('Invalid agent status transport envelope')
}
const serialized = JSON.stringify(parsed)
if (
measureUtf8ByteLength(serialized, {
stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes
}).exceededLimit
) {
throw new Error('Agent status transport envelope exceeds its serialized-byte limit')
}
return serialized
}
export function deserializeAgentStatusTransportEnvelope(
serialized: string
): AgentStatusTransportEnvelope | null {
if (
measureUtf8ByteLength(serialized, {
stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes
}).exceededLimit
) {
return null
}
try {
assertJsonTextStructureWithinLimits(serialized, ENVELOPE_STRUCTURE_LIMITS)
return parseAgentStatusTransportEnvelope(JSON.parse(serialized))
} catch {
return null
}
}
@@ -1,4 +1,5 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -44,7 +45,14 @@ process.kill = function(pid, signal) {
};
`
async function runProbe(options: { signal?: string; census?: string; censusKillDenied?: boolean }) {
async function runProbe(options: {
signal?: string
census?: string
censusKillDenied?: boolean
/** Replaces the whole stub `lsof` body, for cases about what lsof answered. */
lsof?: string
sockPath?: string
}) {
const dir = mkdtempSync(join(tmpdir(), 'orca-lsof-lifecycle-'))
const pidFile = join(dir, 'lsof.pid')
const psPidFile = join(dir, 'ps.pid')
@@ -52,7 +60,7 @@ async function runProbe(options: { signal?: string; census?: string; censusKillD
writeFileSync(join(dir, 'preload.cjs'), PRELOAD)
writeFileSync(
join(dir, 'lsof'),
`#!/bin/sh\necho 123\n${options.signal ? 'exec sleep 60\n' : 'exit 2\n'}`,
options.lsof ?? `#!/bin/sh\necho 123\n${options.signal ? 'exec sleep 60\n' : 'exit 2\n'}`,
{ mode: 0o755 }
)
if (options.census !== undefined) {
@@ -61,7 +69,13 @@ async function runProbe(options: { signal?: string; census?: string; censusKillD
const started = performance.now()
const result = await runProcess({
program: process.execPath,
args: ['--require', join(dir, 'preload.cjs'), '-e', RELAY_LSOF_PROBE_JS, '/unused.sock'],
args: [
'--require',
join(dir, 'preload.cjs'),
'-e',
RELAY_LSOF_PROBE_JS,
options.sockPath ?? '/unused.sock'
],
env: {
...process.env,
ORCA_BACKGROUND_LAUNCH: '1',
@@ -151,3 +165,79 @@ describe.skipIf(process.platform === 'win32')('lsof supervisor lifecycle', () =>
expect(result.elapsedMs).toBeLessThan(4000)
})
})
/**
* When the probe runs as a uid that does not own the socket's holder, `lsof -t -a -U <path>`
* exits 1 with no stdout and no stderr. Measured on Debian 12: a live relay owned by root,
* probed as `nobody`, produced `exit=1 stdout=[] stderr=[]` — byte-identical to a genuinely
* stale socket, so no amount of inspecting lsof's answer can separate the two. That answer
* reaches `verdict: exited / evidence: no-holder`, which `classifySupersededRelay` turns into
* `rm -f` on an inode a live relay still holds.
*
* The stub reproduces that exact shape rather than switching uid, which needs root and two
* accounts. What separates the cases is /proc/net/unix, which is world-readable.
*/
const linuxOnly = describe.skipIf(process.platform !== 'linux')
linuxOnly('lsof blindness to another uid', () => {
const BLIND_LSOF = '#!/bin/sh\nexit 1\n'
it('has the evidence these assertions depend on', () => {
// Why asserted rather than assumed: without /proc/net/unix every case below passes
// vacuously, and a silently degraded suite would stop covering the only thing that
// separates "lsof could not see" from "nothing holds it".
expect(existsSync('/proc/net/unix')).toBe(true)
})
async function withBoundSocket<T>(run: (sockPath: string) => Promise<T>): Promise<T> {
const dir = mkdtempSync(join(tmpdir(), 'orca-lsof-bound-'))
const sockPath = join(dir, 'held.sock')
const server = createServer(() => {})
await new Promise<void>((resolve) => {
server.listen(sockPath, () => resolve())
})
try {
return await run(sockPath)
} finally {
server.close()
rmSync(dir, { recursive: true, force: true })
}
}
it('reports unavailable when nothing was reported for a socket that is still bound', async () => {
const result = await withBoundSocket((sockPath) => runProbe({ lsof: BLIND_LSOF, sockPath }))
expect(result.stdout).toBe('unavailable\n\n')
})
it('still reports lsof for an empty answer about a path nothing has bound', async () => {
// The control that keeps this from becoming a universal accumulation bug: on a healthy
// host a genuinely stale socket has no /proc/net/unix entry and must stay reapable.
const dir = mkdtempSync(join(tmpdir(), 'orca-lsof-stale-'))
try {
const result = await runProbe({ lsof: BLIND_LSOF, sockPath: join(dir, 'never-bound.sock') })
expect(result.stdout).toBe('lsof\n\n')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('keeps a reported pid even while the path is still bound', async () => {
const result = await withBoundSocket((sockPath) =>
runProbe({ lsof: '#!/bin/sh\necho 123\nexit 0\n', sockPath })
)
expect(result.stdout).toBe('lsof\n123\n')
})
// Both neighbours of the bound path, because a substring test would pass one and fail the
// other: `<sock>` is a prefix of the bound path, and the bound path is a prefix of
// `<sock>.other`. Only exact equality answers correctly for both.
it.each([
['a prefix of the bound path', (sockPath: string) => sockPath.slice(0, -1)],
['a path the bound one prefixes', (sockPath: string) => `${sockPath}.other`]
])('does not treat %s as bound', async (_name, derive) => {
const result = await withBoundSocket((sockPath) =>
runProbe({ lsof: BLIND_LSOF, sockPath: derive(sockPath) })
)
expect(result.stdout).toBe('lsof\n\n')
})
})
@@ -20,6 +20,24 @@ function groupExists(pid) {
try { process.kill(-pid, 0); return true; }
catch (error) { return error.code !== 'ESRCH'; }
}
// An empty lsof answer means "nobody holds it" only if lsof could see. Running as a uid that
// does not own the holder, lsof exits 1 with no stdout and no stderr -- byte-identical to a
// genuinely stale socket. /proc/net/unix is world-readable and lists every bound unix socket
// regardless of owner, so an entry for this path alongside no pid proves lsof was blind.
// Absent off Linux, where this returns false and the marker stays whatever it already was.
function pathStillBound(target) {
var text;
try { text = require('fs').readFileSync('/proc/net/unix', 'utf8'); }
catch (error) { return false; }
var lines = text.split('\n');
for (var i = 0; i < lines.length; i++) {
// Num: RefCount Protocol Flags Type St Inode Path -- the path is the line remainder, so
// one containing spaces survives intact.
var match = lines[i].match(/^\S+:(?:\s+\S+){5}\s+\d+ (.*)$/);
if (match && match[1] === target) return true;
}
return false;
}
function finish(unconfirmed) {
if (finished) return;
finished = true;
@@ -36,6 +54,10 @@ function finish(unconfirmed) {
unavailable = true;
return false;
});
// Only an otherwise-clean empty answer needs corroborating; a reported pid stands on its own.
if (!unconfirmed && !unavailable && !stderrSeen && !pids.length && pathStillBound(process.argv[1])) {
unavailable = true;
}
var marker = unconfirmed ? 'cleanup-unconfirmed' : (unavailable || stderrSeen ? 'unavailable' : 'lsof');
process.stdout.write(marker + '\n' + pids.join('\n') + '\n', function() { process.exit(0); });
}
+2 -2
View File
@@ -103,11 +103,11 @@ export type DashboardCard = {
leafId: string | null
/** Agent pane that spawned this agent, when both are visible. */
parentPaneKey?: string
/** Direct workspace parent. The map uses it only when both workspace rings are visible. */
/** Direct workspace parent. */
parentWorktreeId?: string
repoName: string
worktreeName: string
/** Optional for preload compatibility with snapshots produced before Agent Map. */
/** Optional for preload compatibility with snapshots produced by older hosts. */
hostKind?: DashboardCardHostKind
/** Exact owner used by in-window workspace actions when IDs collide across hosts. */
executionHostId?: ExecutionHostId
@@ -33,6 +33,7 @@ export type NotificationDispatchRequest = {
paneKey?: string
repoLabel?: string
worktreeLabel?: string
/** Legacy senders may still provide this; project labels are now always shown. */
hasMultipleActiveRepos?: boolean
terminalTitle?: string
isActiveWorktree?: boolean
@@ -59,8 +59,6 @@ const INVENTORY: readonly InventoryGroup[] = [
['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2],
'src/renderer/src/components/automations/automation-draft-model.ts',
['src/renderer/src/components/automations/automation-list-search-rows.ts', 2],
['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2],
['src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx', 2],
['src/renderer/src/components/settings/NativeChatSupportedAgents.tsx', 2],
['src/renderer/src/components/settings/QuickCommandsList.tsx', 2],
['src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx', 2],
+26 -2
View File
@@ -130,6 +130,11 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'terminal.create-idempotency.v2' as const
// Why: an older host strips terminal.create's unknown `shell` and answers with a terminal running
// the host default shell. That reply is indistinguishable from success, so a client asking for a
// shell must refuse rather than create the wrong one.
export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY =
'terminal.create-shell-selection.v1' as const
export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const
export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY =
'session-tabs.authoritative-inventory.v1' as const
@@ -230,6 +235,22 @@ export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
// Hosts without this capability have no notifications.registerPush RPC.
export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const
/**
* `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the
* surface, instead of each client routing for itself.
*
* Negotiated rather than assumed because a client that cannot see it must keep using
* `worktree.create` + `startupAgent`, which stays supported verbatim. The reverse skew is the
* dangerous one: `worktree.create` returns `agentTerminalHandle` only when a startup agent was
* requested, so a host that quietly routed that call to a structured session would hand an old
* client a response with no handle and no error.
*
* Advertising it is a statement that the client understands EITHER outcome, since the host is what
* picks: a structured session it can open, or a terminal agent. A client that renders only one of
* the two keeps using the surface-specific methods.
*/
export const AGENT_LAUNCH_RUNTIME_CAPABILITY = 'agent.launch.v1' as const
// Generic native clients include the CLI and must not claim Electron-only page
// placement support.
export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
@@ -239,7 +260,8 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY
] as const
// Electron clients can decode client-hosted page placement; becoming a page
@@ -296,6 +318,7 @@ export const RUNTIME_CAPABILITIES = [
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
@@ -333,7 +356,8 @@ export const RUNTIME_CAPABILITIES = [
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY
NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
+1
View File
@@ -1160,6 +1160,7 @@ export const RPC_PARAMS_BY_METHOD = {
// Why: these methods bind a schema the shared contract cannot hold because its value
// graph reaches into src/main. Listing them keeps the gap visible instead of absent.
export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [
'agent.launch',
'emulator.install',
'orchestration.send',
'orchestration.taskUpdate'
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { TerminalCreateParams } from './terminal-unary-params'
import { resolveWindowsShellStartupFamily } from '../windows-terminal-shell'
describe('TerminalCreateParams.shell', () => {
it('stays optional so older callers keep creating terminals', () => {
const parsed = TerminalCreateParams.parse({ worktree: 'path:/repo' })
expect(parsed.shell).toBeUndefined()
})
it('carries an allowed Windows shell through to the runtime', () => {
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd.exe' }).shell).toBe(
'cmd.exe'
)
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'git-bash' }).shell).toBe(
'git-bash'
)
})
// The host canonicalizes even when a client did not, so the spawn path and the startup-command
// quoting only ever see the `.exe` spelling they exact-match.
it('canonicalizes an accepted spelling before it reaches the runtime', () => {
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd' }).shell).toBe(
'cmd.exe'
)
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'pwsh' }).shell).toBe(
'pwsh.exe'
)
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'Git-Bash' }).shell).toBe(
'git-bash'
)
})
it('quotes a bare cmd override as cmd rather than PowerShell', () => {
const { shell } = TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd' })
expect(resolveWindowsShellStartupFamily(shell)).toBe('cmd')
})
// The relay refuses these at spawn time; refusing here turns an opaque spawn failure into an
// answer the caller gets before the terminal exists.
it('refuses a shell the host will not spawn', () => {
expect(() => TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'nu.exe' })).toThrow(
/shell must be one of/
)
expect(() =>
TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd.exe && calc' })
).toThrow(/shell must be one of/)
})
})
@@ -1,6 +1,11 @@
import { z } from 'zod'
import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
import { isTuiAgent } from '../tui-agent-config'
import {
canonicalizeWindowsShellOverride,
isSupportedWindowsShellOverride,
listSupportedWindowsShellOverrides
} from '../windows-terminal-shell'
import { TERMINAL_PANE_SPLIT_SOURCES } from '../feature-education-telemetry'
export const TerminalHandle = z.object({
@@ -180,7 +185,18 @@ export const TerminalCreateParams = z.object({
activate: z.unknown().optional(),
presentation: z.enum(['background', 'focused']).optional(),
tabId: OptionalString,
leafId: OptionalString
leafId: OptionalString,
// Why refused at the boundary rather than at spawn: only the host knows the allowlist, and a
// relay-side throw reaches the caller as an opaque spawn failure after the round trip.
shell: z
.string()
.refine(isSupportedWindowsShellOverride, {
message: `shell must be one of: ${listSupportedWindowsShellOverrides().join(', ')}`
})
// Why here: the host is authoritative, so it canonicalizes even when a client did not; the
// spawn path exact-matches `.exe` spellings and must never see `cmd` or `Git-Bash`.
.transform((shell) => canonicalizeWindowsShellOverride(shell) ?? shell)
.optional()
})
export const TerminalSplit = TerminalHandle.extend({
+2
View File
@@ -263,6 +263,8 @@ type RuntimeTerminalCreateBaseRequestPayload = {
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
/** Windows shell the created tab spawns AS, instead of the host default. */
shellOverride?: string
}
export type RuntimeTerminalCreateRequestPayload =
@@ -108,7 +108,6 @@ describe('scanSourceTree filesystem traversal', () => {
expect(statSync).toHaveBeenCalledExactlyOnceWith(join(root, 'alias'))
})
it('still reports a broken link instead of silently dropping it', () => {
const target = join(root, '.target')
mkdirSync(target)
@@ -5,7 +5,8 @@
import type {
AgentJournalRenderItem,
AgentJournalToolCallItem
AgentJournalToolCallItem,
AgentJournalTurnLifecycle
} from './agent-session-journal-types'
import { readAgentJournalTurn } from './agent-session-turn-record'
@@ -21,6 +22,27 @@ export function activeStructuredAgentSessionTurnId(
return null
}
/** The same verdict for reduced items a caller holds unordered, so a reader that already has them
* need not render and sort a whole snapshot to ask. Sequence is the ordering key the render pass
* sorts on, and ties resolve to the later-reduced item exactly as that stable sort would. */
export function activeStructuredAgentSessionTurnIdBySequence(
items: Iterable<AgentJournalRenderItem>
): string | null {
let newestSequence = 0
let newest: AgentJournalTurnLifecycle | null = null
for (const item of items) {
if (item.sequence < newestSequence) {
continue
}
const turn = readAgentJournalTurn(item.body)
if (turn) {
newestSequence = item.sequence
newest = turn
}
}
return newest?.state === 'running' ? newest.turnId : null
}
/**
* Whether the newest thing the active turn produced is the model's own reasoning.
*
@@ -15,6 +15,7 @@ export type StructuredAgentSessionOutboxEntry = {
queuedAt: number
lastAttemptAt: number | null
retryAfterUnknownSubmittedAt: number | null
source?: 'launch'
}
export type StructuredAgentSessionAttachment = {
@@ -157,7 +158,8 @@ export function parseStructuredAgentSessionOutboxEntry(
retryAfterUnknownSubmittedAt:
typeof entry.retryAfterUnknownSubmittedAt === 'number'
? entry.retryAfterUnknownSubmittedAt
: null
: null,
...(entry.source === 'launch' ? { source: 'launch' as const } : {})
}
}
@@ -56,21 +56,20 @@ describe('per-launch structured feasibility', () => {
expect(support({ agent })).toEqual({ supported: true })
})
it.each([
const blockerCases: [string, Partial<StructuredNativeChatSupportInput>, string][] = [
['a reused PTY agent', { reusesTerminal: true }, 'reused-terminal'],
['grok', { agent: 'grok' }, 'agent-without-structured-session'],
['openclaude', { agent: 'openclaude' }, 'agent-without-structured-session'],
['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'],
['a custom TUI launch', { requiresTuiLaunchCustomization: true }, 'tui-launch-customization'],
['a custom TUI launch command', { requiresTuiLaunchCommand: true }, 'tui-launch-command'],
['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'],
['a missing capability', { hostCapabilities: [] }, 'runtime-capability'],
['an unanswered host', { hostCapabilities: null }, 'runtime-capability-unknown']
] as [string, Partial<StructuredNativeChatSupportInput>, string][])(
'names %s as the blocker',
(_name, overrides, blocker) => {
expect(support(overrides)).toEqual({ supported: false, blocker })
}
)
]
it.each(blockerCases)('names %s as the blocker', (_name, overrides, blocker) => {
expect(support(overrides)).toEqual({ supported: false, blocker })
})
// The client cannot see whether the host can read a provider child's start time, so neither
// provider is refused here on platform; agentSession.createSupport answers that at create time.
@@ -24,7 +24,10 @@ export type StructuredNativeChatBlocker =
| 'reused-terminal'
| 'agent-without-structured-session'
| 'floating-workspace'
| 'tui-launch-customization'
/** The agent's launch command is overridden, or the launch names its own working directory:
* a process shape only a PTY can produce. The configured *arguments* are not read here —
* they are a terminal concern the structured transports do not share a vocabulary with. */
| 'tui-launch-command'
| 'remote-execution-host'
| 'project-runtime'
| 'runtime-capability'
@@ -43,7 +46,7 @@ export type StructuredNativeChatSupportInput = {
hostCapabilities: readonly string[] | null
workspaceKind?: 'git-worktree' | 'folder' | 'floating'
projectRuntime?: ProjectExecutionRuntimeResolution | null
requiresTuiLaunchCustomization?: boolean
requiresTuiLaunchCommand?: boolean
/** An existing PTY agent keeps its execution transport. */
reusesTerminal?: boolean
}
@@ -81,8 +84,8 @@ export function resolveStructuredNativeChatSupport(
if (input.workspaceKind === 'floating') {
return { supported: false, blocker: 'floating-workspace' }
}
if (input.requiresTuiLaunchCustomization === true) {
return { supported: false, blocker: 'tui-launch-customization' }
if (input.requiresTuiLaunchCommand === true) {
return { supported: false, blocker: 'tui-launch-command' }
}
const projectRuntime = input.projectRuntime
if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') {
@@ -0,0 +1,21 @@
import type { GlobalSettings } from './global-settings-types'
import type { TuiAgent } from './tui-agent'
/**
* Whether the user replaced this agent's launch command with one only a terminal can run.
*
* Shared rather than renderer-local because both launch surfaces have to answer it: the renderer
* routes such a launch back to the TUI, and orchestration falls a worker back to a PTY so the
* custom command still applies.
*
* Arguments and environment are deliberately not read here. Structured native chat applies the
* configured environment itself, and the Arguments field is a terminal/TUI concern: structured
* chat drives Claude through the Agent SDK and Codex through app-server, whose option sets are
* independently versioned and need not match the interactive CLI's.
*/
export function hasExplicitTuiLaunchCommand(
settings: Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> | null | undefined,
agent: TuiAgent
): boolean {
return Boolean(settings?.agentCmdOverrides?.[agent]?.trim())
}
@@ -1,43 +0,0 @@
import type { GlobalSettings } from './global-settings-types'
import type { TuiAgent } from './tui-agent'
import { getTuiAgentDefaultArgs, getTuiAgentDefaultEnv } from './tui-agent-launch-defaults'
/**
* Whether the user configured a TUI launch this agent would lose outside a terminal.
*
* Shared rather than renderer-local because both launch surfaces have to answer it: the renderer
* routes such a launch back to the TUI, and orchestration falls a worker back to a PTY so the
* custom command, arguments and environment still apply.
*/
export function hasExplicitTuiLaunchCustomization(
settings:
| Partial<Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>>
| null
| undefined,
agent: TuiAgent
): boolean {
const configuredArgs = settings?.agentDefaultArgs?.[agent]
const configuredEnv = settings?.agentDefaultEnv?.[agent]
const defaultEnv = getTuiAgentDefaultEnv(agent)
const envIsCustomized =
configuredEnv !== undefined &&
(Object.keys(configuredEnv).length !== Object.keys(defaultEnv).length ||
Object.entries(configuredEnv).some(([key, value]) => defaultEnv[key] !== value))
return (
Boolean(settings?.agentCmdOverrides?.[agent]?.trim()) ||
hasExplicitTuiAgentArgs(agent, configuredArgs) ||
envIsCustomized
)
}
export function hasSemanticallyNonEmptyAgentArgs(value: string | null | undefined): boolean {
return Boolean(value?.trim())
}
export function hasExplicitTuiAgentArgs(
agent: TuiAgent,
value: string | null | undefined
): boolean {
const trimmed = value?.trim() ?? ''
return trimmed.length > 0 && trimmed !== getTuiAgentDefaultArgs(agent).trim()
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
resolveTuiAgentLaunchArgs,
tuiAgentArgsBypassPermissions
} from './tui-agent-launch-defaults'
describe('tuiAgentArgsBypassPermissions', () => {
// The Agent Permissions toggle has no storage of its own: Yolo is the presence of the agent's
// bypass flag in the arguments string, wherever the user has written the rest of the field.
it.each([
['claude', '--dangerously-skip-permissions', true],
['claude', '--dangerously-skip-permissions --model Opus', true],
['claude', '--model Opus --dangerously-skip-permissions', true],
['claude', '', false],
['claude', '--model Opus', false],
// A token boundary, so a longer flag that merely starts the same way is not a bypass.
['claude', '--dangerously-skip-permissions-not-really', false],
['codex', '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol', true],
['codex', '--model gpt-5.6-sol', false]
] as const)('reads %s args %s as %s', (agent, args, expected) => {
expect(tuiAgentArgsBypassPermissions(agent, args)).toBe(expected)
})
it('reads no bypass out of an absent or non-string value', () => {
expect(tuiAgentArgsBypassPermissions('claude', null)).toBe(false)
expect(tuiAgentArgsBypassPermissions('claude', undefined)).toBe(false)
})
})
describe('resolveTuiAgentLaunchArgs', () => {
// A terminal launch still applies the whole configured string verbatim; only the structured
// route stopped reading it.
it('hands the configured arguments to a terminal launch unchanged', () => {
expect(
resolveTuiAgentLaunchArgs('claude', {
claude: '--dangerously-skip-permissions --model Opus'
})
).toBe('--dangerously-skip-permissions --model Opus')
})
it('falls back to the agent default when nothing is configured', () => {
expect(resolveTuiAgentLaunchArgs('claude', {})).toBe('--dangerously-skip-permissions')
expect(resolveTuiAgentLaunchArgs('claude', { claude: '' })).toBe('')
})
})
+29
View File
@@ -23,6 +23,21 @@ export function hasUnsupportedTuiAgentArgs(agent: TuiAgent, value: unknown): boo
return (UNSUPPORTED_TUI_AGENT_ARGS[agent] ?? []).some((arg) => argPattern(arg).test(value))
}
/**
* Whether the configured arguments carry this agent's permission-bypass flag.
*
* The Agent Permissions toggle has no storage of its own — it writes and reads this flag inside
* the arguments string — so presence at a token boundary, not whole-string equality, is what
* "Yolo" means. A terminal launch applies the flag wherever else the user has written in the field.
*/
export function tuiAgentArgsBypassPermissions(
agent: TuiAgent,
value: string | null | undefined
): boolean {
const bypassArg = YOLO_TUI_AGENT_ARGS[agent]
return typeof value === 'string' && bypassArg !== undefined && argPattern(bypassArg).test(value)
}
function sanitizeTuiAgentLaunchArgs(agent: TuiAgent, args: string): string {
const unsupportedArgs = UNSUPPORTED_TUI_AGENT_ARGS[agent]
if (!unsupportedArgs) {
@@ -93,6 +108,20 @@ export function resolveTuiAgentLaunchArgs(
return getTuiAgentDefaultArgs(agent)
}
/**
* Whether this agent's *resolved* launch arguments ask for a permission bypass.
*
* Resolved, not configured: an untouched Arguments field falls back to the default Orca ships,
* which is the bypass flag, so bypass is the posture a user gets until they choose otherwise.
* Choosing Manual stores an empty string, which owns the key and so beats that default.
*/
export function resolvedTuiAgentArgsBypassPermissions(
agent: TuiAgent,
configuredArgs: Partial<Record<TuiAgent, string>> | null | undefined
): boolean {
return tuiAgentArgsBypassPermissions(agent, resolveTuiAgentLaunchArgs(agent, configuredArgs))
}
export function resolveTuiAgentLaunchEnv(
agent: TuiAgent,
configuredEnv: Partial<Record<TuiAgent, Record<string, string>>> | null | undefined
+18
View File
@@ -45,6 +45,24 @@ describe('tui agent startup plans', () => {
}
)
// Structured native chat stopped reading the configured arguments; a terminal launch must
// still spell every token of them, in order, exactly as the user wrote them.
it('passes the whole configured argument string to a terminal launch', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: '',
agentArgs: resolveTuiAgentLaunchArgs('claude', {
claude: '--dangerously-skip-permissions --model Opus'
}),
cmdOverrides: {},
platform: 'linux',
allowEmptyPromptLaunch: true
})
// Every token, in order, shell-quoted as the terminal path has always quoted them.
expect(plan?.launchCommand).toBe("claude '--dangerously-skip-permissions' '--model' 'Opus'")
})
it('uses POSIX quoting when the target shell is Linux', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
+10
View File
@@ -1,3 +1,6 @@
/** Chromium's zoom-level base: one level step multiplies rendered size by this. */
export const UI_ZOOM_BASE = 1.2
export const UI_ZOOM_STEP = 0.5
export const UI_ZOOM_MIN = -3
export const UI_ZOOM_MAX = 5
@@ -13,3 +16,10 @@ export function stepUIZoomLevel(current: number, direction: UIZoomDirection): nu
const next = direction === 'in' ? current + UI_ZOOM_STEP : current - UI_ZOOM_STEP
return Math.max(UI_ZOOM_MIN, Math.min(UI_ZOOM_MAX, next))
}
/** The scale Chromium applies to renderer CSS pixels at this zoom level.
* Renderer CSS px x factor = window DIP, which is why native geometry
* (traffic lights, OS cursor coords, emulated guest viewports) must convert. */
export function uiZoomFactorFromLevel(level: number): number {
return UI_ZOOM_BASE ** level
}
+86 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { resolveWindowsShellStartupFamily } from './windows-terminal-shell'
import {
canonicalizeWindowsShellOverride,
isSupportedWindowsShellOverride,
listSupportedWindowsShellOverrides,
resolveWindowsShellStartupFamily
} from './windows-terminal-shell'
describe('resolveWindowsShellStartupFamily', () => {
it('defaults to PowerShell when unset', () => {
@@ -33,3 +38,83 @@ describe('resolveWindowsShellStartupFamily', () => {
expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash')).toBe('posix')
})
})
describe('isSupportedWindowsShellOverride', () => {
// Spelled out rather than looped over the list, which would assert the list against itself.
it('accepts exactly the shells the relay is willing to spawn', () => {
expect(listSupportedWindowsShellOverrides()).toEqual([
'bash',
'bash.exe',
'cmd',
'cmd.exe',
'git-bash',
'powershell',
'powershell.exe',
'pwsh',
'pwsh.exe',
'wsl',
'wsl.exe'
])
expect(isSupportedWindowsShellOverride('cmd.exe')).toBe(true)
expect(isSupportedWindowsShellOverride('powershell.exe')).toBe(true)
expect(isSupportedWindowsShellOverride('git-bash')).toBe(true)
})
it('accepts a differently cased spelling of an allowed shell', () => {
expect(isSupportedWindowsShellOverride('CMD.EXE')).toBe(true)
expect(isSupportedWindowsShellOverride('PowerShell.exe')).toBe(true)
})
// The allowlist is what stops `--shell` from naming an arbitrary executable to spawn.
it('refuses anything else, including a path to an allowed shell', () => {
expect(isSupportedWindowsShellOverride('nu.exe')).toBe(false)
expect(isSupportedWindowsShellOverride('')).toBe(false)
expect(isSupportedWindowsShellOverride('C:\\Windows\\System32\\cmd.exe')).toBe(false)
expect(isSupportedWindowsShellOverride('cmd.exe /c calc')).toBe(false)
})
})
describe('canonicalizeWindowsShellOverride', () => {
it('maps every accepted spelling to the `.exe` name the spawn path exact-matches', () => {
expect(canonicalizeWindowsShellOverride('cmd')).toBe('cmd.exe')
expect(canonicalizeWindowsShellOverride('cmd.exe')).toBe('cmd.exe')
expect(canonicalizeWindowsShellOverride('powershell')).toBe('powershell.exe')
expect(canonicalizeWindowsShellOverride('powershell.exe')).toBe('powershell.exe')
expect(canonicalizeWindowsShellOverride('pwsh')).toBe('pwsh.exe')
expect(canonicalizeWindowsShellOverride('pwsh.exe')).toBe('pwsh.exe')
expect(canonicalizeWindowsShellOverride('wsl')).toBe('wsl.exe')
expect(canonicalizeWindowsShellOverride('wsl.exe')).toBe('wsl.exe')
expect(canonicalizeWindowsShellOverride('bash')).toBe('bash.exe')
expect(canonicalizeWindowsShellOverride('bash.exe')).toBe('bash.exe')
expect(canonicalizeWindowsShellOverride('git-bash')).toBe('git-bash')
})
// pwsh (PowerShell 7) and powershell (Windows PowerShell 5.1) are different binaries.
it('never collapses pwsh into powershell', () => {
expect(canonicalizeWindowsShellOverride('pwsh')).not.toBe('powershell.exe')
expect(canonicalizeWindowsShellOverride('pwsh.exe')).not.toBe('powershell.exe')
})
// `resolveWindowsGitBashShellPath` compares the marker case-sensitively.
it('folds case so a mixed-case spelling reaches the exact-match consumers', () => {
expect(canonicalizeWindowsShellOverride('Git-Bash')).toBe('git-bash')
expect(canonicalizeWindowsShellOverride('CMD')).toBe('cmd.exe')
expect(canonicalizeWindowsShellOverride('PowerShell.exe')).toBe('powershell.exe')
})
it('returns undefined for anything the allowlist refuses', () => {
expect(canonicalizeWindowsShellOverride('nu.exe')).toBeUndefined()
expect(canonicalizeWindowsShellOverride('C:\\Windows\\System32\\cmd.exe')).toBeUndefined()
expect(canonicalizeWindowsShellOverride('')).toBeUndefined()
})
// Bare `cmd` falls through resolveWindowsShellStartupFamily to PowerShell quoting; the canonical
// name is what keeps the PTY shell and the queued-command quoting in the same family.
it('yields the cmd startup family for a bare cmd override', () => {
expect(resolveWindowsShellStartupFamily('cmd')).toBe('powershell')
expect(resolveWindowsShellStartupFamily(canonicalizeWindowsShellOverride('cmd'))).toBe('cmd')
expect(resolveWindowsShellStartupFamily(canonicalizeWindowsShellOverride('Git-Bash'))).toBe(
'posix'
)
})
})
+44
View File
@@ -54,3 +54,47 @@ export function resolveLocalWindowsAgentStartupShell(args: {
}
return resolveWindowsShellStartupFamily(args.terminalWindowsShell)
}
/**
* Shell names a caller may request for a single Windows terminal, keyed by accepted spelling and
* mapped to the one canonical spelling every downstream consumer keys on.
*
* The relay owns the spawn and has always refused anything outside this set, but the set lived
* only there — so a bad value from `terminal create --shell` surfaced as a spawn-time throw with
* no way for the CLI to answer before the round trip. Shared so the RPC boundary and the relay
* agree on the same names.
*
* Why canonicalize: `resolveWindowsShellStartupFamily`, the launch-arg builders, and the Git Bash
* path resolver all exact-match the `.exe` spelling, so a bare `cmd` accepted here would spawn cmd
* yet quote its startup command for PowerShell. `pwsh` and `powershell` are different binaries and
* are never collapsed into each other.
*/
const WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES: ReadonlyMap<string, string> = new Map([
['powershell.exe', 'powershell.exe'],
['powershell', 'powershell.exe'],
['pwsh.exe', 'pwsh.exe'],
['pwsh', 'pwsh.exe'],
['cmd.exe', 'cmd.exe'],
['cmd', 'cmd.exe'],
['wsl.exe', 'wsl.exe'],
['wsl', 'wsl.exe'],
// Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay
// the one host that hard-failed a setting the local and daemon PTYs accept.
['bash.exe', 'bash.exe'],
['bash', 'bash.exe'],
[WINDOWS_GIT_BASH_SHELL, WINDOWS_GIT_BASH_SHELL]
])
/** Canonical spelling for an accepted override (case-insensitive), or undefined when refused. */
export function canonicalizeWindowsShellOverride(shell: string): string | undefined {
return WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.get(shell.toLowerCase())
}
export function isSupportedWindowsShellOverride(shell: string): boolean {
return canonicalizeWindowsShellOverride(shell) !== undefined
}
/** Sorted for a stable error message; callers list these when refusing a value. */
export function listSupportedWindowsShellOverrides(): string[] {
return [...WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.keys()].sort()
}