mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -28,11 +28,18 @@ describe('legacy agent-status adapter', () => {
|
||||
expect(adapter.view.get(entry.paneKey)).toBe(entry)
|
||||
})
|
||||
|
||||
it('refuses keys already owned by the canonical projection', () => {
|
||||
it('refuses structured rows and keys already owned by the canonical projection', () => {
|
||||
const canonicalPaneKeys = new Set<string>()
|
||||
const adapter = createAgentStatusLegacyAdapter({
|
||||
isCanonicalPaneKey: (paneKey) => canonicalPaneKeys.has(paneKey)
|
||||
})
|
||||
const structured = { ...status('structured-pane'), structuredHost: 'owned' as const }
|
||||
|
||||
expect(
|
||||
adapter.admit('main-status-update', AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, structured)
|
||||
).toBe(false)
|
||||
expect(adapter.view.size).toBe(0)
|
||||
|
||||
const prior = status('canonical-pane', 'legacy before canonical publication')
|
||||
expect(adapter.admit('main-status-update', AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, prior)).toBe(
|
||||
true
|
||||
@@ -137,21 +144,27 @@ describe('legacy agent-status adapter', () => {
|
||||
expect(adapter.view.get('immutable')?.payload.prompt).toBe('work')
|
||||
})
|
||||
|
||||
it('assigns listing order once per live row and preserves it across refresh and move', () => {
|
||||
it('preserves Map insertion order across refresh, explicit reorder and relocation', () => {
|
||||
let nextOrder = 40
|
||||
const adapter = createAgentStatusLegacyAdapter({ nextListingOrder: () => nextOrder++ })
|
||||
adapter.admit('main-status-update', AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, status('pane'))
|
||||
expect(adapter.listingOrder('pane')).toBe(40)
|
||||
|
||||
adapter.admit(
|
||||
'main-status-update',
|
||||
AGENT_STATUS_2A_CURRENT_PRODUCER_MODE,
|
||||
status('pane', 'ordinary refresh')
|
||||
)
|
||||
expect(adapter.listingOrder('pane')).toBe(40)
|
||||
adapter.admit(
|
||||
'main-status-update',
|
||||
AGENT_STATUS_2A_CURRENT_PRODUCER_MODE,
|
||||
status('pane', 'refresh'),
|
||||
{ moveToEnd: true }
|
||||
)
|
||||
expect(adapter.listingOrder('pane')).toBe(40)
|
||||
expect(adapter.listingOrder('pane')).toBe(41)
|
||||
adapter.move('pane', 'moved-pane')
|
||||
expect(adapter.listingOrder('moved-pane')).toBe(40)
|
||||
expect(adapter.listingOrder('moved-pane')).toBe(42)
|
||||
|
||||
adapter.delete('moved-pane')
|
||||
adapter.admit(
|
||||
@@ -159,6 +172,6 @@ describe('legacy agent-status adapter', () => {
|
||||
AGENT_STATUS_2A_CURRENT_PRODUCER_MODE,
|
||||
status('moved-pane', 'new lifecycle')
|
||||
)
|
||||
expect(adapter.listingOrder('moved-pane')).toBe(41)
|
||||
expect(adapter.listingOrder('moved-pane')).toBe(43)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,6 +69,11 @@ export function canAdmitLegacyAgentStatus(
|
||||
|
||||
export type AgentStatusLegacyAdapter = {
|
||||
readonly view: ReadonlyMap<string, AgentHookEventPayload>
|
||||
canAdmit(
|
||||
caller: AgentStatusLegacyIngressCaller,
|
||||
mode: AgentStatusLegacyAdmissionMode,
|
||||
entry: AgentHookEventPayload
|
||||
): boolean
|
||||
admit(
|
||||
caller: AgentStatusLegacyIngressCaller,
|
||||
mode: AgentStatusLegacyAdmissionMode,
|
||||
@@ -140,14 +145,19 @@ export function createAgentStatusLegacyAdapter(
|
||||
const nextListingOrder = options.nextListingOrder ?? (() => nextLocalListingOrder++)
|
||||
const isCanonicalPaneKey = options.isCanonicalPaneKey ?? (() => false)
|
||||
const view = createReadonlyView(entries)
|
||||
const canAdmit: AgentStatusLegacyAdapter['canAdmit'] = (caller, mode, entry) =>
|
||||
entry.structuredHost === undefined &&
|
||||
!isCanonicalPaneKey(entry.paneKey) &&
|
||||
canAdmitLegacyAgentStatus(caller, mode)
|
||||
|
||||
return {
|
||||
view,
|
||||
canAdmit,
|
||||
admit: (caller, mode, entry, admitOptions = {}) => {
|
||||
if (isCanonicalPaneKey(entry.paneKey) || !canAdmitLegacyAgentStatus(caller, mode)) {
|
||||
if (!canAdmit(caller, mode, entry)) {
|
||||
return false
|
||||
}
|
||||
if (!listingOrderByPaneKey.has(entry.paneKey)) {
|
||||
if (!listingOrderByPaneKey.has(entry.paneKey) || admitOptions.moveToEnd) {
|
||||
const order = nextListingOrder()
|
||||
if (!Number.isSafeInteger(order) || order < 0) {
|
||||
throw new RangeError(
|
||||
@@ -181,7 +191,6 @@ export function createAgentStatusLegacyAdapter(
|
||||
}
|
||||
const movedKey = `${toPaneKey}${key.slice(fromPaneKey.length)}`
|
||||
const priorTargetOrder = listingOrderByPaneKey.get(movedKey)
|
||||
const sourceOrder = listingOrderByPaneKey.get(key)
|
||||
entries.delete(key)
|
||||
listingOrderByPaneKey.delete(key)
|
||||
if (isCanonicalPaneKey(movedKey)) {
|
||||
@@ -190,8 +199,8 @@ export function createAgentStatusLegacyAdapter(
|
||||
entries.set(movedKey, value)
|
||||
if (priorTargetOrder !== undefined) {
|
||||
listingOrderByPaneKey.set(movedKey, priorTargetOrder)
|
||||
} else if (sourceOrder !== undefined) {
|
||||
listingOrderByPaneKey.set(movedKey, sourceOrder)
|
||||
} else {
|
||||
listingOrderByPaneKey.set(movedKey, nextListingOrder())
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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); });
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 & {})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user