diff --git a/src/relay/agent-status-store-relay-context.test.ts b/src/relay/agent-status-store-relay-context.test.ts index 853722f3cf0..a54459aa022 100644 --- a/src/relay/agent-status-store-relay-context.test.ts +++ b/src/relay/agent-status-store-relay-context.test.ts @@ -7,6 +7,10 @@ import { makeStructuredAgentStatusSubject } from '../shared/agent-status-subject const SHARED_CORE_FILES = [ 'agent-status-child-work.ts', 'agent-status-child-work-codec.ts', + 'agent-status-child-work-activity-codec.ts', + 'agent-status-child-work-legality.ts', + 'agent-status-child-work-value-guards.ts', + 'agent-status-child-work-view.ts', 'agent-status-child-work-admission.ts', 'agent-status-child-work-admission-core.ts', 'agent-status-child-work-admission-operations.ts', diff --git a/src/shared/agent-status-child-work-activity-codec.ts b/src/shared/agent-status-child-work-activity-codec.ts new file mode 100644 index 00000000000..83e07087f69 --- /dev/null +++ b/src/shared/agent-status-child-work-activity-codec.ts @@ -0,0 +1,101 @@ +import { + AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH, + AGENT_CHILD_WORK_OPERATION_BASES, + AGENT_CHILD_WORK_RESIDENCIES, + type AgentChildWorkInput, + type AgentChildWorkOperation, + type AgentChildWorkOperationBasis, + type AgentChildWorkResidency +} from './agent-status-child-work' +import { + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_STATUS_TOOL_NAME_MAX_LENGTH +} from './agent-status-types' +import { + hasOnlyKeys, + isBoundedString, + isChildWorkText, + isRecord, + isTimestamp +} from './agent-status-child-work-value-guards' + +const RESIDENCY_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_RESIDENCIES) +const OPERATION_BASIS_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_OPERATION_BASES) + +export type AgentChildWorkActivityFields = Pick< + AgentChildWorkInput, + 'parentChildWorkId' | 'residency' | 'operation' | 'lastMessage' +> + +type AgentChildWorkActivityClock = Pick< + AgentChildWorkInput, + 'childWorkId' | 'firstObservedAt' | 'observedAt' +> + +export function isAgentChildWorkResidency(value: unknown): value is AgentChildWorkResidency { + return typeof value === 'string' && RESIDENCY_SET.has(value) +} + +export function isAgentChildWorkOperationBasis( + value: unknown +): value is AgentChildWorkOperationBasis { + return typeof value === 'string' && OPERATION_BASIS_SET.has(value) +} + +/** An owner is another child's id; a child never owns itself. */ +export function isAgentChildWorkOwner(value: unknown, childWorkId: string): value is string { + return isBoundedString(value) && value !== childWorkId +} + +function parseOperation( + value: unknown, + clock: AgentChildWorkActivityClock +): AgentChildWorkOperation | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ['toolName', 'basis', 'observedAt'], ['input']) || + !isChildWorkText(value.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) || + (value.input !== undefined && + !isChildWorkText(value.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)) || + !isAgentChildWorkOperationBasis(value.basis) || + !isTimestamp(value.observedAt) || + // The record's own clock is the newest evidence for this child, so it bounds the operation's. + value.observedAt < clock.firstObservedAt || + value.observedAt > clock.observedAt + ) { + return null + } + return { + toolName: value.toolName, + ...(typeof value.input === 'string' ? { input: value.input } : {}), + basis: value.basis, + observedAt: value.observedAt + } +} + +/** Null when any present field is outside what admission can produce. */ +export function parseAgentChildWorkActivityFields( + value: Record, + clock: AgentChildWorkActivityClock +): AgentChildWorkActivityFields | null { + const operation = + value.operation === undefined ? undefined : parseOperation(value.operation, clock) + if ( + operation === null || + (value.parentChildWorkId !== undefined && + !isAgentChildWorkOwner(value.parentChildWorkId, clock.childWorkId)) || + (value.residency !== undefined && !isAgentChildWorkResidency(value.residency)) || + (value.lastMessage !== undefined && + !isChildWorkText(value.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH)) + ) { + return null + } + return { + ...(typeof value.parentChildWorkId === 'string' + ? { parentChildWorkId: value.parentChildWorkId } + : {}), + ...(isAgentChildWorkResidency(value.residency) ? { residency: value.residency } : {}), + ...(operation ? { operation } : {}), + ...(typeof value.lastMessage === 'string' ? { lastMessage: value.lastMessage } : {}) + } +} diff --git a/src/shared/agent-status-child-work-admission-activity.test.ts b/src/shared/agent-status-child-work-admission-activity.test.ts new file mode 100644 index 00000000000..66328bbc422 --- /dev/null +++ b/src/shared/agent-status-child-work-admission-activity.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createAgentChildWorkAdmission, + type AgentChildWorkAnnounceRequest +} from './agent-status-child-work-admission' +import { createAgentStatusStore } from './agent-status-store' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'ssh:host-a', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + 'session_11111111-1111-4111-8111-111111111111' +) + +function observation( + overrides: Partial = {} +): AgentChildWorkAnnounceRequest { + return { + parent, + provider: 'claude', + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], + fence: { invocationId: 'invocation-1', generation: 1 }, + lifetime: 'current', + kind: 'agent', + state: 'working', + membership: 'live', + observedAt: 10, + stoppable: true, + provenance: { source: 'structured-session', producerId: 'journal-1' }, + ...overrides + } +} + +function setup() { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let sequence = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: vi.fn(() => `child-${++sequence}`) + }) + return { store, admission } +} + +describe('child-work admission of what a child is doing', () => { + it('folds raw provider text to the one-line previews a status row carries', () => { + const { store, admission } = setup() + const command = ` npm test\n-- --run ${'x'.repeat(200)}` + expect( + admission.announce( + observation({ + operation: { + toolName: `mcp__${'long_server_name_'.repeat(5)}tool`, + input: command, + basis: 'open', + observedAt: 10 + }, + lastMessage: 'Line one\n\nLine two' + }) + ) + ).toMatchObject({ accepted: true }) + + const child = store.getChild('child-1') + expect(child?.operation?.toolName).toHaveLength(60) + expect(child?.operation?.input?.startsWith('npm test -- --run xxx')).toBe(true) + expect(child?.operation?.input).toHaveLength(160) + expect(child?.lastMessage).toBe('Line one Line two') + }) + + it('keeps a preview whose cut lands on a space or whose text carries control characters', () => { + const { store, admission } = setup() + admission.announce( + observation({ + operation: { + toolName: 'Bash', + input: `${'x'.repeat(159)} --tail`, + basis: 'open', + observedAt: 10 + }, + lastMessage: `${'y'.repeat(511)} more` + }) + ) + expect(store.getChild('child-1')?.operation?.input).toBe('x'.repeat(159)) + expect(store.getChild('child-1')?.lastMessage).toBe('y'.repeat(511)) + + admission.announce( + observation({ + observedAt: 11, + operation: { toolName: 'Bash', input: "cut -d'\t' -f1", basis: 'open', observedAt: 11 }, + lastMessage: 'col1\tcol2 \u001b[31mred' + }) + ) + expect(store.getChild('child-1')?.operation?.input).toBe("cut -d' ' -f1") + expect(store.getChild('child-1')?.lastMessage).toBe('col1 col2 [31mred') + }) + + it('clamps an operation stamped in another clock into the child evidence window', () => { + const { store, admission } = setup() + admission.announce( + observation({ operation: { toolName: 'Bash', basis: 'open', observedAt: 5_000 } }) + ) + expect(store.getChild('child-1')?.operation).toMatchObject({ observedAt: 10 }) + admission.announce( + observation({ + observedAt: 20, + operation: { toolName: 'Read', basis: 'reported', observedAt: 3 } + }) + ) + expect(store.getChild('child-1')?.operation).toEqual({ + toolName: 'Read', + basis: 'reported', + observedAt: 10 + }) + }) + + it('carries owner and residency through, and clears the operation of a parked child', () => { + const { store, admission } = setup() + admission.announce(observation()) + admission.announce( + observation({ + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'shell-1' }], + kind: 'command', + parentChildWorkId: 'child-1', + residency: 'background' + }) + ) + expect(store.getChild('child-2')).toMatchObject({ + parentChildWorkId: 'child-1', + residency: 'background' + }) + + admission.announce( + observation({ + state: 'idle', + observedAt: 12, + operation: { toolName: 'Bash', basis: 'reported', observedAt: 11 } + }) + ) + expect(store.getChild('child-1')).toMatchObject({ state: 'idle' }) + expect(store.getChild('child-1')).not.toHaveProperty('operation') + }) +}) + +describe('child-work identity by provider thread', () => { + it('keeps one host id for a subagent named by its own thread', () => { + const { store, admission } = setup() + const codexChild = observation({ + provider: 'codex', + aliases: [{ segmentId: 'thread-parent', aliasKind: 'thread_id', alias: 'thread-child' }], + fence: { invocationId: 'turn-1', generation: 1 }, + residency: 'background' + }) + expect(admission.announce(codexChild)).toMatchObject({ accepted: true, created: true }) + expect(admission.announce({ ...codexChild, observedAt: 12 })).toMatchObject({ + accepted: true, + childWorkId: 'child-1', + created: false + }) + expect(store.getAliasesForChild('child-1')).toMatchObject([ + { aliasKind: 'thread_id', alias: 'thread-child' } + ]) + }) +}) + +describe('child-work settlement stamping', () => { + it('admits a settle that still names an operation, clears it and keeps the last message', () => { + const { store, admission } = setup() + admission.announce( + observation({ operation: { toolName: 'Bash', basis: 'open', observedAt: 10 } }) + ) + + expect( + admission.announce( + observation({ + state: 'done', + membership: 'settled', + outcome: 'failed', + observedAt: 20, + operation: { toolName: 'Bash', basis: 'open', observedAt: 10 }, + lastMessage: 'Exit code 1' + }) + ) + ).toMatchObject({ accepted: true }) + const child = store.getChild('child-1') + expect(child).toMatchObject({ + membership: 'settled', + outcome: 'failed', + settledAt: 20, + lastMessage: 'Exit code 1' + }) + expect(child).not.toHaveProperty('operation') + }) + + it('stamps the settle time once and keeps it through later settled evidence', () => { + const { store, admission } = setup() + admission.announce(observation()) + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'succeeded', observedAt: 20 }) + ) + expect( + admission.announce( + observation({ + state: 'done', + membership: 'settled', + outcome: 'succeeded', + observedAt: 25, + lastMessage: 'Summary arrived late' + }) + ) + ).toMatchObject({ accepted: true }) + expect(store.getChild('child-1')).toMatchObject({ observedAt: 25, settledAt: 20 }) + }) + + it('accepts later evidence for an ending first reported without an outcome', () => { + const { store, admission } = setup() + admission.announce(observation()) + admission.announce(observation({ state: 'done', membership: 'settled', observedAt: 20 })) + expect( + admission.announce( + observation({ + state: 'done', + membership: 'settled', + observedAt: 22, + lastMessage: 'Final words' + }) + ) + ).toMatchObject({ accepted: true }) + expect(store.getChild('child-1')).toMatchObject({ + outcome: 'unknown', + settledAt: 20, + lastMessage: 'Final words' + }) + }) + + it('refines an ending first recorded as unknown, keeping when it settled', () => { + const { store, admission } = setup() + admission.announce(observation()) + // Roster omission lands first, the frame naming the outcome in the same tick. + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'unknown', observedAt: 20 }) + ) + expect( + admission.announce( + observation({ + state: 'done', + membership: 'settled', + outcome: 'failed', + observedAt: 20, + lastMessage: 'Exit code 1' + }) + ) + ).toMatchObject({ accepted: true, created: false }) + expect(store.getChild('child-1')).toMatchObject({ + outcome: 'failed', + settledAt: 20, + lastMessage: 'Exit code 1' + }) + }) + + it('never changes a definite ending to a different one', () => { + const { store, admission } = setup() + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'succeeded', observedAt: 20 }) + ) + const before = store.getChild('child-1') + expect( + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'failed', observedAt: 25 }) + ) + ).toEqual({ accepted: false, reason: 'stale-invocation' }) + expect(store.getChild('child-1')).toEqual(before) + }) + + it.each([ + ['an explicit unknown', { outcome: 'unknown' }], + ['an omitted outcome', {}] + ] as const)('keeps a definite ending through %s and admits the rest of it', (_case, ending) => { + const { store, admission } = setup() + admission.announce( + observation({ + state: 'done', + membership: 'settled', + outcome: 'cancelled', + observedAt: 20, + lastMessage: 'Stopped by user' + }) + ) + expect( + admission.announce( + observation({ + state: 'done', + membership: 'settled', + observedAt: 25, + lastMessage: 'Cleanup finished', + aliases: [ + { segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }, + { segmentId: 'segment-1', aliasKind: 'tool_use_id', alias: 'toolu-1' } + ], + ...ending + }) + ) + ).toMatchObject({ accepted: true, childWorkId: 'child-1', created: false }) + expect(store.getChild('child-1')).toMatchObject({ + outcome: 'cancelled', + settledAt: 20, + observedAt: 25, + lastMessage: 'Cleanup finished' + }) + expect(store.getAliasesForChild('child-1').map((alias) => alias.alias)).toContain('toolu-1') + }) + + it('stamps a child first seen already settled at that observation', () => { + const { store, admission } = setup() + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'cancelled', observedAt: 14 }) + ) + expect(store.getChild('child-1')).toMatchObject({ firstObservedAt: 14, settledAt: 14 }) + }) + + it('records when the previous invocation settled, not its newest evidence, on resume', () => { + const { store, admission } = setup() + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'failed', observedAt: 20 }) + ) + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'failed', observedAt: 25 }) + ) + + expect( + admission.resume({ + ...observation({ observedAt: 30 }), + childWorkId: 'child-1', + expectedFence: { invocationId: 'invocation-1', generation: 1 }, + nextFence: { invocationId: 'invocation-2', generation: 2 } + }) + ).toMatchObject({ accepted: true }) + const child = store.getChild('child-1') + expect(child?.previousInvocations).toEqual([ + { + fence: { invocationId: 'invocation-1', generation: 1 }, + outcome: 'failed', + settledAt: 20 + } + ]) + expect(child).toMatchObject({ membership: 'live', state: 'working' }) + expect(child).not.toHaveProperty('settledAt') + expect(child).not.toHaveProperty('outcome') + }) +}) diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index bc8b9e4fe34..31c9db7cd42 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -4,14 +4,35 @@ import { type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' import { + AGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH, + AGENT_CHILD_WORK_LABEL_MAX_LENGTH, + AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH, agentChildWorkFencesEqual, type AgentChildWorkId, type AgentChildWorkInput, type AgentChildWorkInvocationFence, type AgentChildWorkKind, + type AgentChildWorkOperation, type AgentChildWorkRecord } from './agent-status-child-work' -import { parseAgentChildWorkInput } from './agent-status-child-work-codec' +import { + isAgentChildWorkOperationBasis, + isAgentChildWorkOwner, + isAgentChildWorkResidency +} from './agent-status-child-work-activity-codec' +import { + parseAgentChildWorkInput, + parseAgentChildWorkProviderTiming +} from './agent-status-child-work-codec' +import { + isChildWorkTokenCount, + normalizeChildWorkText +} from './agent-status-child-work-value-guards' +import { agentChildWorkAllowsOperation } from './agent-status-child-work-legality' +import { + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_STATUS_TOOL_NAME_MAX_LENGTH +} from './agent-status-types' import type { AgentChildWorkAdmissionResult, AgentChildWorkAdoptRequest, @@ -73,35 +94,161 @@ export function buildAgentChildWorkAliases( return built } +/** The fields only the host writes: identity, the invocation, and when it settled. */ +export type AgentChildWorkHostFields = { + childWorkId: AgentChildWorkId + firstObservedAt: number + invocation: AgentChildWorkInvocationFence + previousInvocations?: AgentChildWorkInput['previousInvocations'] + settledAt?: number +} + +/** Stamped once, when the current invocation first settles; later settled evidence keeps it. */ +export function agentChildWorkSettledAt( + request: Pick, + current?: Pick +): number | undefined { + if (request.membership !== 'settled') { + return undefined + } + return current?.membership === 'settled' && current.settledAt !== undefined + ? current.settledAt + : request.observedAt +} + +/** Request fields that describe the child, as opposed to its lifecycle, clock or provenance. */ +type AgentChildWorkFactKey = Exclude< + keyof AgentChildWorkObservationFields, + 'kind' | 'state' | 'membership' | 'observedAt' | 'stoppable' | 'provenance' +> + +/** Every fact is listed, so a request field added without a parse and a merge rule fails to + * compile. `undefined` means the observation did not say it. */ +type AgentChildWorkFacts = { + [K in AgentChildWorkFactKey]-?: AgentChildWorkObservationFields[K] | undefined +} + +function parseObservedOperation( + request: AgentChildWorkObservationFields, + firstObservedAt: number +): AgentChildWorkOperation | undefined { + const operation = request.operation + if ( + !operation || + !agentChildWorkAllowsOperation(request.membership, request.state) || + !isAgentChildWorkOperationBasis(operation.basis) || + !Number.isFinite(operation.observedAt) + ) { + return undefined + } + const toolName = normalizeChildWorkText(operation.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) + const input = normalizeChildWorkText(operation.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + return toolName + ? { + toolName, + ...(input ? { input } : {}), + basis: operation.basis, + // A producer may stamp provider time; the host bounds it to the child's evidence window. + observedAt: Math.min(Math.max(operation.observedAt, firstObservedAt), request.observedAt) + } + : undefined +} + +/** Provider facts are untrusted: each becomes a value the record codec accepts, or "not said". + * A malformed fact is dropped here so it can neither reject the observation nor erase what + * the record already knows. */ +function parseObservationFacts( + request: AgentChildWorkObservationFields, + host: Pick +): AgentChildWorkFacts { + const timing = + request.providerTiming === undefined + ? null + : parseAgentChildWorkProviderTiming(request.providerTiming) + return { + outcome: request.outcome, + name: normalizeChildWorkText(request.name, AGENT_CHILD_WORK_LABEL_MAX_LENGTH), + description: normalizeChildWorkText( + request.description, + AGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH + ), + agentType: normalizeChildWorkText(request.agentType, AGENT_CHILD_WORK_LABEL_MAX_LENGTH), + model: normalizeChildWorkText(request.model, AGENT_CHILD_WORK_LABEL_MAX_LENGTH), + totalTokens: isChildWorkTokenCount(request.totalTokens) ? request.totalTokens : undefined, + providerTiming: timing ?? undefined, + parentChildWorkId: isAgentChildWorkOwner(request.parentChildWorkId, host.childWorkId) + ? request.parentChildWorkId + : undefined, + residency: isAgentChildWorkResidency(request.residency) ? request.residency : undefined, + operation: parseObservedOperation(request, host.firstObservedAt), + lastMessage: normalizeChildWorkText( + request.lastMessage, + AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH + ) + } +} + +/** A sparse observation never erases what the record knows (a roster omission knows only "it is + * gone"). `run` is the stored record only when this observation continues its invocation. */ +function mergeObservationFacts( + said: AgentChildWorkFacts, + prior: AgentChildWorkRecord | undefined, + run: AgentChildWorkRecord | undefined +): AgentChildWorkFacts { + return { + // Refine-only: an `unknown` ending claims nothing, so a definite one stands. + outcome: + said.outcome === undefined || said.outcome === 'unknown' + ? (run?.outcome ?? said.outcome) + : said.outcome, + name: said.name ?? prior?.name, + description: said.description ?? prior?.description, + agentType: said.agentType ?? prior?.agentType, + model: said.model ?? prior?.model, + residency: said.residency ?? prior?.residency, + // Cumulative, so a late or duplicate frame never shrinks it. + totalTokens: + said.totalTokens !== undefined && prior?.totalTokens !== undefined + ? Math.max(said.totalTokens, prior.totalTokens) + : (said.totalTokens ?? prior?.totalTokens), + // Whoever started this run owns it; a restart names its own spawner, or none for the main agent. + parentChildWorkId: said.parentChildWorkId ?? run?.parentChildWorkId, + lastMessage: said.lastMessage ?? run?.lastMessage, + // The provider's start and end of this run: a restart has not completed. + providerTiming: said.providerTiming ?? run?.providerTiming, + // Its absence means the child stopped doing it. + operation: said.operation + } +} + +/** Builds the record admission writes: parse the observation, merge it over `prior` (the stored + * record on update and resume), and let the codec check the invariants. */ export function buildAgentChildWork( request: AgentChildWorkObservationFields & { parent: AgentStatusSubject provider: string }, - childWorkId: string, - firstObservedAt: number, - invocation: AgentChildWorkInvocationFence, - previousInvocations?: AgentChildWorkInput['previousInvocations'] + host: AgentChildWorkHostFields, + prior?: AgentChildWorkRecord ): AgentChildWorkInput | null { + const run = + prior && agentChildWorkFencesEqual(prior.invocation, host.invocation) ? prior : undefined return parseAgentChildWorkInput({ - childWorkId, + childWorkId: host.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, + ...mergeObservationFacts(parseObservationFacts(request, host), prior, run), + firstObservedAt: host.firstObservedAt, observedAt: request.observedAt, + ...(host.settledAt !== undefined ? { settledAt: host.settledAt } : {}), stoppable: request.stoppable, - invocation, - ...(previousInvocations !== undefined ? { previousInvocations } : {}), + invocation: host.invocation, + ...(host.previousInvocations !== undefined + ? { previousInvocations: host.previousInvocations } + : {}), provenance: request.provenance }) } @@ -123,6 +270,21 @@ export function commitAgentChildWork( : rejectAgentChildWorkAdmission('store-rejected') } +/** Settled history only gains precision: an `unknown` ending may become a definite one (a roster + * omission can land a tick before the frame naming the outcome), and a definite ending never + * changes to another. An omitted outcome counts as `unknown`. */ +function conflictsWithSettled( + child: AgentChildWorkRecord, + request: AgentChildWorkAnnounceRequest | AgentChildWorkAdoptRequest +): boolean { + const requested = request.outcome ?? 'unknown' + return ( + request.membership !== 'settled' || + request.state !== child.state || + (requested !== 'unknown' && child.outcome !== 'unknown' && requested !== child.outcome) + ) +} + export function updateExistingAgentChildWork( store: AgentStatusStore, request: AgentChildWorkAnnounceRequest | AgentChildWorkAdoptRequest, @@ -130,20 +292,19 @@ export function updateExistingAgentChildWork( aliases: AgentChildWorkAliasInput[], removeAliases: string[] = [] ): AgentChildWorkAdmissionResult { - if ( - child.membership === 'settled' && - (request.membership !== 'settled' || - request.state !== child.state || - request.outcome !== child.outcome) - ) { + if (child.membership === 'settled' && conflictsWithSettled(child, request)) { return rejectAgentChildWorkAdmission('stale-invocation') } const updated = buildAgentChildWork( request, - child.childWorkId, - child.firstObservedAt, - child.invocation, - child.previousInvocations + { + childWorkId: child.childWorkId, + firstObservedAt: child.firstObservedAt, + invocation: child.invocation, + previousInvocations: child.previousInvocations, + settledAt: agentChildWorkSettledAt(request, child) + }, + child ) return updated ? commitAgentChildWork(store, updated, aliases, false, removeAliases) diff --git a/src/shared/agent-status-child-work-admission-operations.ts b/src/shared/agent-status-child-work-admission-operations.ts index 2fb86e49c2e..00f39a0b0fb 100644 --- a/src/shared/agent-status-child-work-admission-operations.ts +++ b/src/shared/agent-status-child-work-admission-operations.ts @@ -2,6 +2,7 @@ import { serializeAgentChildWorkBindingKey } from './agent-status-child-work-bin import { agentChildWorkFencesEqual, type AgentChildWorkId } from './agent-status-child-work' import { agentChildWorkAliasesForChild, + agentChildWorkSettledAt, buildAgentChildWork, buildAgentChildWorkAliases, commitAgentChildWork, @@ -64,7 +65,12 @@ export function announceAgentChildWork( if (!aliases || findAgentChildWork(store, candidateId)) { return rejectAgentChildWorkAdmission(aliases ? 'id-collision' : 'invalid') } - const child = buildAgentChildWork(request, candidateId, request.observedAt, fence) + const child = buildAgentChildWork(request, { + childWorkId: candidateId, + firstObservedAt: request.observedAt, + invocation: fence, + settledAt: agentChildWorkSettledAt(request) + }) return child ? commitAgentChildWork(store, child, aliases, true) : rejectAgentChildWorkAdmission('invalid') @@ -110,7 +116,12 @@ export function announceAgentChildWork( candidateId, fence ) - const child = buildAgentChildWork(request, candidateId, request.observedAt, fence) + const child = buildAgentChildWork(request, { + childWorkId: candidateId, + firstObservedAt: request.observedAt, + invocation: fence, + settledAt: agentChildWorkSettledAt(request) + }) return aliases && child ? commitAgentChildWork(store, child, aliases, true) : rejectAgentChildWorkAdmission('invalid') diff --git a/src/shared/agent-status-child-work-admission-parse.test.ts b/src/shared/agent-status-child-work-admission-parse.test.ts new file mode 100644 index 00000000000..d4988e22d39 --- /dev/null +++ b/src/shared/agent-status-child-work-admission-parse.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createAgentChildWorkAdmission, + type AgentChildWorkAnnounceRequest +} from './agent-status-child-work-admission' +import type { AgentChildWorkRecord } from './agent-status-child-work' +import { normalizeChildWorkText } from './agent-status-child-work-value-guards' +import { createAgentStatusStore } from './agent-status-store' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'ssh:host-a', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + 'session_11111111-1111-4111-8111-111111111111' +) + +function observation( + overrides: Partial = {} +): AgentChildWorkAnnounceRequest { + return { + parent, + provider: 'claude', + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], + fence: { invocationId: 'invocation-1', generation: 1 }, + lifetime: 'current', + kind: 'agent', + state: 'working', + membership: 'live', + observedAt: 10, + stoppable: true, + provenance: { source: 'structured-session', producerId: 'journal-1' }, + ...overrides + } +} + +function setup() { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let sequence = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: vi.fn(() => `child-${++sequence}`) + }) + return { store, admission } +} + +type TextField = { + cap: number + carry: (raw: string) => Partial + read: (child: AgentChildWorkRecord | null) => string | undefined +} + +const TEXT_FIELDS: Record = { + name: { cap: 512, carry: (name) => ({ name }), read: (child) => child?.name }, + description: { + cap: 8_000, + carry: (description) => ({ description }), + read: (child) => child?.description + }, + agentType: { cap: 512, carry: (agentType) => ({ agentType }), read: (c) => c?.agentType }, + model: { cap: 512, carry: (model) => ({ model }), read: (child) => child?.model }, + 'operation.toolName': { + cap: 60, + carry: (toolName) => ({ operation: { toolName, basis: 'open', observedAt: 10 } }), + read: (child) => child?.operation?.toolName + }, + 'operation.input': { + cap: 160, + carry: (input) => ({ operation: { toolName: 'Bash', input, basis: 'open', observedAt: 10 } }), + read: (child) => child?.operation?.input + }, + lastMessage: { cap: 512, carry: (lastMessage) => ({ lastMessage }), read: (c) => c?.lastMessage } +} + +function hostileText(cap: number): [string, string][] { + return [ + ['a tab', 'col1\tcol2'], + ['a CRLF', 'line one\r\nline two'], + ['a line separator', 'one\u2028two'], + ['a paragraph separator', 'one\u2029two'], + ['a next-line control', 'one\u0085two'], + ['a no-break space inside and around', '\u00a0keep\u00a0this\u00a0'], + ['an escape sequence', 'plain \u001b[31mred'], + ['a delete', 'rub\u007fout'], + ['whitespace only', ' \t\r\n\u2028 '], + // A space at character cap - 1, cap and cap + 1: just inside the cut, on it, and past it. + ...[-1, 0, 1].map((offset): [string, string] => [ + `a space at character cap${offset < 0 ? '' : '+'}${offset}`, + `${'x'.repeat(cap + offset - 1)} tail` + ]), + ['a surrogate pair split by the cut', `${'x'.repeat(cap - 1)}\u{1f600}tail`], + ['a surrogate pair ending at the cut', `${'x'.repeat(cap - 2)}\u{1f600}tail`] + ] +} + +function breaksOneLine(text: string): boolean { + return [...text].some((char) => { + const code = char.charCodeAt(0) + return code <= 0x1f || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029 + }) +} + +describe('child-work admission parses provider text into what the codec stores', () => { + const cases = Object.entries(TEXT_FIELDS).flatMap(([field, spec]) => + hostileText(spec.cap).map(([label, raw]) => [field, label, raw, spec] as const) + ) + + it.each(cases)('%s with %s is admitted at a fixed point', (_field, _label, raw, spec) => { + const { store, admission } = setup() + expect(admission.announce(observation(spec.carry(raw)))).toMatchObject({ accepted: true }) + const stored = spec.read(store.getChild('child-1')) + expect(stored).toBe(normalizeChildWorkText(raw, spec.cap)) + if (stored === undefined) { + return + } + expect(normalizeChildWorkText(stored, spec.cap)).toBe(stored) + // Independent of the normalizer: one line, trimmed, within the cap, no half pair at the cut. + expect(breaksOneLine(stored)).toBe(false) + expect(stored).toBe(stored.trim()) + expect(stored.length).toBeLessThanOrEqual(spec.cap) + const last = stored.charCodeAt(stored.length - 1) + expect(last >= 0xd800 && last <= 0xdbff).toBe(false) + }) + + it('keeps a surrogate pair whole when it ends exactly at the cap', () => { + const { store, admission } = setup() + admission.announce(observation({ lastMessage: `${'x'.repeat(510)}\u{1f600}tail` })) + expect(store.getChild('child-1')?.lastMessage).toBe(`${'x'.repeat(510)}\u{1f600}`) + }) +}) + +function carrying(field: string, value: unknown): AgentChildWorkAnnounceRequest { + const request = observation({ observedAt: 20 }) + // Producers are typed; this stands in for a producer bug the type cannot express. + Reflect.set(request, field, value) + return request +} + +const GOOD = { + name: 'researcher', + description: 'Map the codebase', + agentType: 'Explore', + model: 'model-a', + totalTokens: 5_000, + providerTiming: { startedAt: 3 }, + parentChildWorkId: 'child-owner', + residency: 'background', + lastMessage: 'wrote 3 files' +} as const + +// One row per descriptive fact: the record holds a good value and the request carries a bad one. +const ERASURE: [keyof typeof GOOD, string, unknown][] = [ + ['name', 'whitespace only', ' \t '], + ['description', 'a bare line break', '\r\n'], + ['agentType', 'a line separator only', '\u2028'], + ['model', 'an empty string', ''], + ['totalTokens', 'a negative count', -1], + ['totalTokens', 'a fractional count', 1.5], + ['totalTokens', 'NaN', Number.NaN], + ['totalTokens', 'a count past the safe integers', 2 ** 60], + ['providerTiming', 'a negative time', { startedAt: -1 }], + ['providerTiming', 'an unknown key', { startedAt: 3, extra: 1 }], + ['parentChildWorkId', 'an empty id', ''], + ['parentChildWorkId', 'its own id', 'child-1'], + ['parentChildWorkId', 'an id over 256 characters', 'x'.repeat(257)], + ['residency', 'an unknown residency', 'detached'], + ['lastMessage', 'line breakers only', '\u0085 \u2029'] +] + +describe('a malformed fact never erases what the record knows', () => { + it.each(ERASURE)('keeps %s through a request carrying %s', (field, _case, bad) => { + const { store, admission } = setup() + expect(admission.announce(observation(GOOD))).toMatchObject({ accepted: true }) + expect(admission.announce(carrying(field, bad))).toMatchObject({ + accepted: true, + childWorkId: 'child-1' + }) + expect(store.getChild('child-1')).toMatchObject({ ...GOOD, observedAt: 20 }) + }) + + it('admits a first sighting whose facts are all malformed, with none of them', () => { + const { store, admission } = setup() + const request = carrying('residency', 'detached') + Reflect.set(request, 'totalTokens', -1) + Reflect.set(request, 'parentChildWorkId', 'child-1') + Reflect.set(request, 'name', '\u2028') + expect(admission.announce(request)).toMatchObject({ accepted: true, created: true }) + const child = store.getChild('child-1') + for (const field of ['residency', 'totalTokens', 'parentChildWorkId', 'name']) { + expect(child).not.toHaveProperty(field) + } + }) + + it('reads a malformed operation as the child doing nothing it can name', () => { + const { store, admission } = setup() + admission.announce( + observation({ operation: { toolName: 'Bash', basis: 'open', observedAt: 10 } }) + ) + expect( + admission.announce( + carrying('operation', { toolName: 'Read', basis: 'guessed', observedAt: 20 }) + ) + ).toMatchObject({ accepted: true }) + expect(store.getChild('child-1')).not.toHaveProperty('operation') + }) + + it('lands a settle whose token count is malformed, keeping the counted tokens', () => { + const { store, admission } = setup() + admission.announce(observation({ totalTokens: 5_000 })) + expect( + admission.announce( + observation({ + state: 'done', + membership: 'settled', + outcome: 'succeeded', + observedAt: 20, + totalTokens: -1 + }) + ) + ).toMatchObject({ accepted: true }) + expect(store.getChild('child-1')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + totalTokens: 5_000 + }) + }) +}) diff --git a/src/shared/agent-status-child-work-admission-sparse.test.ts b/src/shared/agent-status-child-work-admission-sparse.test.ts new file mode 100644 index 00000000000..d1a678e8f46 --- /dev/null +++ b/src/shared/agent-status-child-work-admission-sparse.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createAgentChildWorkAdmission, + type AgentChildWorkAnnounceRequest +} from './agent-status-child-work-admission' +import { createAgentStatusStore } from './agent-status-store' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'ssh:host-a', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + 'session_11111111-1111-4111-8111-111111111111' +) + +function observation( + overrides: Partial = {} +): AgentChildWorkAnnounceRequest { + return { + parent, + provider: 'claude', + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], + fence: { invocationId: 'invocation-1', generation: 1 }, + lifetime: 'current', + kind: 'agent', + state: 'working', + membership: 'live', + observedAt: 10, + stoppable: true, + provenance: { source: 'structured-session', producerId: 'journal-1' }, + ...overrides + } +} + +const described = { + name: 'researcher', + description: 'Map the codebase', + agentType: 'Explore', + model: 'model-a', + totalTokens: 5_000 +} as const + +function setup() { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let sequence = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: vi.fn(() => `child-${++sequence}`) + }) + return { store, admission } +} + +describe('child-work admission of sparse observations', () => { + it('keeps what the child was called when the ending names only that it is gone', () => { + const { store, admission } = setup() + admission.announce(observation(described)) + expect( + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'unknown', observedAt: 20 }) + ) + ).toMatchObject({ accepted: true }) + expect(store.getChild('child-1')).toMatchObject({ membership: 'settled', ...described }) + }) + + it('keeps the recorded last message when a refinement names only the outcome', () => { + const { store, admission } = setup() + admission.announce(observation({ name: 'researcher' })) + admission.announce( + observation({ + state: 'done', + membership: 'settled', + outcome: 'unknown', + observedAt: 20, + lastMessage: 'wrote 3 files' + }) + ) + expect( + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'failed', observedAt: 21 }) + ) + ).toMatchObject({ accepted: true }) + expect(store.getChild('child-1')).toMatchObject({ + outcome: 'failed', + name: 'researcher', + lastMessage: 'wrote 3 files', + settledAt: 20 + }) + }) + + it('keeps the last message, owner and residency through a settle that names none of them', () => { + const { store, admission } = setup() + admission.announce(observation()) + admission.announce( + observation({ + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'shell-1' }], + kind: 'command', + parentChildWorkId: 'child-1', + residency: 'background', + lastMessage: 'listening on 3000' + }) + ) + admission.announce(observation({ state: 'working', observedAt: 12 })) + expect( + admission.announce( + observation({ + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'shell-1' }], + kind: 'command', + state: 'done', + membership: 'settled', + observedAt: 20 + }) + ) + ).toMatchObject({ accepted: true }) + expect(store.getChild('child-2')).toMatchObject({ + membership: 'settled', + parentChildWorkId: 'child-1', + residency: 'background', + lastMessage: 'listening on 3000' + }) + }) + + it('never shrinks the token count on a late or duplicate frame', () => { + const { store, admission } = setup() + admission.announce(observation({ totalTokens: 5_000 })) + admission.announce(observation({ totalTokens: 100, observedAt: 12 })) + expect(store.getChild('child-1')?.totalTokens).toBe(5_000) + admission.announce(observation({ totalTokens: 7_500, observedAt: 13 })) + expect(store.getChild('child-1')?.totalTokens).toBe(7_500) + }) + + it('refuses a refinement stamped behind the settle it refines and keeps the record', () => { + const { store, admission } = setup() + admission.announce(observation()) + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'unknown', observedAt: 20 }) + ) + const before = store.getChild('child-1') + expect( + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'failed', observedAt: 15 }) + ) + ).toMatchObject({ accepted: false }) + expect(store.getChild('child-1')).toEqual(before) + }) + + it('replaces a label the request carries and keeps the count over an invalid one', () => { + const { store, admission } = setup() + admission.announce(observation(described)) + admission.announce(observation({ name: 'reviewer', observedAt: 11 })) + expect(store.getChild('child-1')).toMatchObject({ ...described, name: 'reviewer' }) + expect(admission.announce(observation({ totalTokens: -1, observedAt: 12 }))).toMatchObject({ + accepted: true + }) + expect(store.getChild('child-1')?.totalTokens).toBe(5_000) + }) + + function settledFirstRun() { + const { store, admission } = setup() + admission.announce( + observation({ + ...described, + state: 'done', + membership: 'settled', + outcome: 'succeeded', + observedAt: 20, + parentChildWorkId: 'child-spawner', + lastMessage: 'First run done', + providerTiming: { startedAt: 12, completedAt: 20 } + }) + ) + const resume = (overrides: Partial = {}) => + admission.resume({ + ...observation({ observedAt: 30, ...overrides }), + childWorkId: 'child-1', + expectedFence: { invocationId: 'invocation-1', generation: 1 }, + nextFence: { invocationId: 'invocation-2', generation: 2 } + }) + return { store, resume } + } + + it('carries labels and tokens into a resumed invocation but not its ending, timing or spawner', () => { + const { store, resume } = settledFirstRun() + expect(store.getChild('child-1')?.providerTiming).toEqual({ startedAt: 12, completedAt: 20 }) + expect(resume()).toMatchObject({ accepted: true }) + const child = store.getChild('child-1') + expect(child).toMatchObject({ membership: 'live', ...described }) + expect(child).not.toHaveProperty('lastMessage') + // The first run's completion time would claim the live restart had already finished. + expect(child).not.toHaveProperty('providerTiming') + // Restarted by the main agent: it no longer nests under the child that first spawned it. + expect(child).not.toHaveProperty('parentChildWorkId') + }) + + it('nests a resumed invocation under the child that restarted it', () => { + const { store, resume } = settledFirstRun() + expect(resume({ parentChildWorkId: 'child-restarter' })).toMatchObject({ accepted: true }) + expect(store.getChild('child-1')?.parentChildWorkId).toBe('child-restarter') + }) +}) diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index 493fba612e2..cf839485be8 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -4,10 +4,12 @@ import type { AgentChildWorkInvocationFence, AgentChildWorkKind, AgentChildWorkMembership, + AgentChildWorkOperation, AgentChildWorkOutcome, AgentChildWorkProviderTiming, AgentChildWorkProvenance, AgentChildWorkRecord, + AgentChildWorkResidency, AgentChildWorkState } from './agent-status-child-work' import { @@ -26,6 +28,10 @@ export type AgentChildWorkObservationAlias = { alias: string } +/** An observation may be sparse, and raw provider text is fine: admission folds text to one line. + * An omitted or malformed label, token count or residency keeps its stored value; the owner, + * last message, provider timing and a definite outcome last only for their invocation. Tokens + * never shrink. Omitting `operation` clears it. */ export type AgentChildWorkObservationFields = { kind: AgentChildWorkKind state: AgentChildWorkState @@ -37,6 +43,12 @@ export type AgentChildWorkObservationFields = { model?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming + parentChildWorkId?: AgentChildWorkId + residency?: AgentChildWorkResidency + /** Admission clamps `observedAt` into [firstObservedAt, request observedAt], and drops the + * operation when the state cannot carry one (settled, idle, unverifiable). */ + operation?: AgentChildWorkOperation + lastMessage?: string observedAt: number stoppable: boolean provenance: AgentChildWorkProvenance diff --git a/src/shared/agent-status-child-work-alias.ts b/src/shared/agent-status-child-work-alias.ts index 323ea6d50b5..539c9c38214 100644 --- a/src/shared/agent-status-child-work-alias.ts +++ b/src/shared/agent-status-child-work-alias.ts @@ -15,7 +15,14 @@ import { const CHILD_ALIAS_KEY_PREFIX = 'agent-child-work-alias-v1:' const MAX_ALIAS_PART_LENGTH = 512 -export type AgentChildWorkAliasKind = 'task_id' | 'tool_use_id' +/** + * `thread_id` names a child by its own provider thread (a Codex subagent). The hook lane registers + * a Claude `agent_id` under `task_id` (it is the same registry id) and a Codex `agent_id` under + * `thread_id`; no `agent_id` kind exists on purpose. + */ +export const AGENT_CHILD_WORK_ALIAS_KINDS = ['task_id', 'tool_use_id', 'thread_id'] as const +export type AgentChildWorkAliasKind = (typeof AGENT_CHILD_WORK_ALIAS_KINDS)[number] +const ALIAS_KIND_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_ALIAS_KINDS) export type AgentChildWorkAliasIdentity = Pick< AgentChildWorkAliasInput, @@ -85,6 +92,10 @@ function isKind(value: unknown): value is AgentChildWorkKind { ) } +function isAliasKind(value: unknown): value is AgentChildWorkAliasKind { + return typeof value === 'string' && ALIAS_KIND_SET.has(value) +} + export function parseAgentChildWorkAliasInput(value: unknown): AgentChildWorkAliasInput | null { if ( !isRecord(value) || @@ -101,7 +112,7 @@ export function parseAgentChildWorkAliasInput(value: unknown): AgentChildWorkAli !isBoundedString(value.provider) || !isBoundedString(value.segmentId) || !isKind(value.kind) || - (value.aliasKind !== 'task_id' && value.aliasKind !== 'tool_use_id') || + !isAliasKind(value.aliasKind) || !isBoundedString(value.alias) || !isBoundedString(value.childWorkId) ) { diff --git a/src/shared/agent-status-child-work-codec-boundary.test.ts b/src/shared/agent-status-child-work-codec-boundary.test.ts new file mode 100644 index 00000000000..8affca519f6 --- /dev/null +++ b/src/shared/agent-status-child-work-codec-boundary.test.ts @@ -0,0 +1,29 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from './source-scan/source-tree-scan' + +const SOURCE_ROOT = resolve(__dirname, '..') +const CODEC_IMPORT = /from\s+['"][^'"]*\/agent-status-child-work-codec['"]/ + +// The host's own store and admission path. The codec rejects a whole record over one unknown key, +// so a reader of another build's records or views must use a permissive or negotiated decoder. +const HOST_INTERNAL_IMPORTERS = [ + 'shared/agent-status-child-work-admission-core.ts', + 'shared/agent-status-child-work-admission-operations.ts', + 'shared/agent-status-child-work-alias.ts', + 'shared/agent-status-child-work-resume.ts', + 'shared/agent-status-store-codec.ts', + 'shared/agent-status-store-mutation.ts', + 'shared/agent-status-store-state.ts', + 'shared/agent-status-store.ts' +] + +describe('child-work record codec boundary', () => { + it('is imported only by the host store and admission modules', () => { + const importers = scanSourceTree(SOURCE_ROOT) + .filter((file) => CODEC_IMPORT.test(file.source)) + .map((file) => file.relativePath) + .sort() + expect(importers).toEqual(HOST_INTERNAL_IMPORTERS) + }) +}) diff --git a/src/shared/agent-status-child-work-codec.ts b/src/shared/agent-status-child-work-codec.ts index 616a7430022..f335bbae37e 100644 --- a/src/shared/agent-status-child-work-codec.ts +++ b/src/shared/agent-status-child-work-codec.ts @@ -1,6 +1,8 @@ import { + AGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH, AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX, AGENT_CHILD_WORK_KINDS, + AGENT_CHILD_WORK_LABEL_MAX_LENGTH, AGENT_CHILD_WORK_MEMBERSHIPS, AGENT_CHILD_WORK_OUTCOMES, AGENT_CHILD_WORK_STATES, @@ -17,53 +19,22 @@ import { type AgentChildWorkState } from './agent-status-child-work' import { parseAgentStatusSubject } from './agent-status-subject' +import { + hasOnlyKeys, + isBoundedString, + isChildWorkText, + isChildWorkTokenCount, + isRecord, + isTimestamp +} from './agent-status-child-work-value-guards' +import { parseAgentChildWorkActivityFields } from './agent-status-child-work-activity-codec' +import { isAgentChildWorkLifecycleLegal } from './agent-status-child-work-legality' -const MAX_ID_LENGTH = 256 -const MAX_LABEL_LENGTH = 512 -const MAX_DESCRIPTION_LENGTH = 8_000 const CHILD_WORK_KIND_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_KINDS) const CHILD_WORK_STATE_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_STATES) const CHILD_WORK_MEMBERSHIP_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_MEMBERSHIPS) const CHILD_WORK_OUTCOME_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_OUTCOMES) -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function hasOnlyKeys( - record: Record, - 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 } @@ -98,7 +69,9 @@ export function parseAgentChildWorkInvocationFence( return { invocationId: value.invocationId, generation: value.generation } } -function parseProviderTiming(value: unknown): AgentChildWorkProviderTiming | null { +export function parseAgentChildWorkProviderTiming( + value: unknown +): AgentChildWorkProviderTiming | null { if (!isRecord(value) || !hasOnlyKeys(value, [], ['startedAt', 'completedAt'])) { return null } @@ -156,10 +129,19 @@ function parseInvocationHistory(value: unknown): AgentChildWorkInvocationHistory return history } -function parseOptionalLabel(value: unknown, maxLength = MAX_LABEL_LENGTH): string | null { - return value === undefined ? '' : isBoundedString(value, maxLength) ? value : null +function parseOptionalLabel( + value: unknown, + maxLength = AGENT_CHILD_WORK_LABEL_MAX_LENGTH +): string | null { + return value === undefined ? '' : isChildWorkText(value, maxLength) ? value : null } +/** The host's integrity gate for its own store, strict by design: an unknown key or enum arm + * rejects the whole record. Never a cross-version decoder — anything reading records or views + * from another build must ignore unknown keys and degrade unknown arms, or negotiate + * (docs/reference/remote-wire-compatibility.md, Rules 1 and 4). + * Every malformed field rejects, descriptive or not: admission drops bad provider facts before + * they get here, so a value outside its image is a writer bug, never data to repair silently. */ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | null { if ( !isRecord(value) || @@ -186,6 +168,11 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | 'model', 'totalTokens', 'providerTiming', + 'parentChildWorkId', + 'residency', + 'operation', + 'lastMessage', + 'settledAt', 'previousInvocations' ] ) || @@ -195,15 +182,12 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | !isState(value.state) || !isMembership(value.membership) || (value.outcome !== undefined && !isOutcome(value.outcome)) || - (value.outcome !== undefined && value.membership !== 'settled') || + (value.settledAt !== undefined && !isTimestamp(value.settledAt)) || !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)) + (value.totalTokens !== undefined && !isChildWorkTokenCount(value.totalTokens)) ) { return null } @@ -211,21 +195,56 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | const invocation = parseAgentChildWorkInvocationFence(value.invocation) const provenance = parseProvenance(value.provenance) const timing = - value.providerTiming === undefined ? undefined : parseProviderTiming(value.providerTiming) + value.providerTiming === undefined + ? undefined + : parseAgentChildWorkProviderTiming(value.providerTiming) const history = value.previousInvocations === undefined ? undefined : parseInvocationHistory(value.previousInvocations) const labels = { name: parseOptionalLabel(value.name), - description: parseOptionalLabel(value.description, MAX_DESCRIPTION_LENGTH), + description: parseOptionalLabel(value.description, AGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH), agentType: parseOptionalLabel(value.agentType), model: parseOptionalLabel(value.model) } - if (!parent || !invocation || !provenance || timing === null || history === null) { + const activity = parseAgentChildWorkActivityFields(value, { + childWorkId: value.childWorkId, + firstObservedAt: value.firstObservedAt, + observedAt: value.observedAt + }) + if ( + !parent || + !invocation || + !provenance || + timing === null || + history === null || + !activity || + Object.values(labels).includes(null) + ) { return null } - if (Object.values(labels).includes(null)) { + // A settled record written without these fields (an older writer) reads as an unknown ending + // at its last evidence, never as success. + const settled = value.membership === 'settled' + const outcome = isOutcome(value.outcome) ? value.outcome : settled ? 'unknown' : undefined + const settledAt = isTimestamp(value.settledAt) + ? value.settledAt + : settled + ? value.observedAt + : undefined + if ( + !isAgentChildWorkLifecycleLegal({ + kind: value.kind, + state: value.state, + membership: value.membership, + outcome, + settledAt, + operation: activity.operation, + firstObservedAt: value.firstObservedAt, + observedAt: value.observedAt + }) + ) { return null } const historyFenceKeys = history?.map( @@ -246,15 +265,17 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | kind: value.kind, state: value.state, membership: value.membership, - ...(isOutcome(value.outcome) ? { outcome: value.outcome } : {}), + ...(outcome !== undefined ? { 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 } : {}), + ...activity, firstObservedAt: value.firstObservedAt, observedAt: value.observedAt, + ...(settledAt !== undefined ? { settledAt } : {}), stoppable: value.stoppable, invocation, ...(history ? { previousInvocations: history } : {}), diff --git a/src/shared/agent-status-child-work-legacy-golden.test.ts b/src/shared/agent-status-child-work-legacy-golden.test.ts new file mode 100644 index 00000000000..f029e54e784 --- /dev/null +++ b/src/shared/agent-status-child-work-legacy-golden.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' +import { + agentChildWorkProjectionCandidateFromBackgroundTask, + projectAgentChildWorkLegacyBackgroundTasks, + projectAgentChildWorkLegacySubagents +} from './agent-status-child-work-projection' + +// Today's inputs: the rows a host publishes on `summary.backgroundTasks`, run through the exact +// path the status bridge uses. The expected values were captured on unmodified main. +const PUBLISHED_TASKS: AgentSessionBackgroundTask[] = [ + { + id: 'task-agent', + kind: 'agent', + state: 'working', + name: 'researcher', + description: 'Investigate', + startedAt: 100, + totalTokens: 12, + stoppable: true + }, + { id: 'task-agent-no-state', kind: 'agent', name: 'planner', startedAt: 110 }, + { + id: 'task-agent-monitoring', + kind: 'agent', + state: 'monitoring', + description: 'Watch', + startedAt: 120, + stoppable: false + }, + { id: 'task-agent-waiting', kind: 'agent', state: 'waiting', startedAt: 130 }, + { + id: 'task-agent-blocked', + kind: 'agent', + state: 'blocked', + name: '', + description: '', + startedAt: 140 + }, + { id: 'task-agent-done', kind: 'agent', state: 'done', startedAt: 150 }, + { id: 'task-agent-idle', kind: 'agent', state: 'idle', startedAt: 160 }, + { id: 'task-agent-unverifiable', kind: 'agent', state: 'unverifiable', startedAt: 170 }, + { id: 'task-agent-no-start', kind: 'agent', state: 'working' }, + { id: ' padded-agent ', kind: 'agent', state: 'working', startedAt: 180 }, + { id: 'x'.repeat(65), kind: 'agent', state: 'working', startedAt: 190 }, + { id: 'task-shell', kind: 'command', state: 'working', description: 'npm test', startedAt: 200 }, + { + id: 'task-monitor', + kind: 'monitor', + state: 'monitoring', + description: 'tail log', + startedAt: 210 + }, + { id: 'task-workflow', kind: 'workflow', startedAt: 220 }, + { id: 'task-unknown', kind: 'unknown', state: 'blocked', startedAt: 230 } +] + +describe('legacy child-work projection of published background tasks', () => { + it('derives the sidebar subagents exactly as the status bridge does today', () => { + const golden = projectAgentChildWorkLegacySubagents( + PUBLISHED_TASKS.map(agentChildWorkProjectionCandidateFromBackgroundTask) + ) + expect(JSON.stringify(golden)).toMatchInlineSnapshot( + `"[{"id":"task-agent","state":"working","startedAt":100,"agentType":"researcher","description":"Investigate"},{"id":"task-agent-no-state","state":"working","startedAt":110,"agentType":"planner"},{"id":"task-agent-monitoring","state":"working","startedAt":120,"description":"Watch"},{"id":"task-agent-waiting","state":"waiting","startedAt":130},{"id":"task-agent-blocked","state":"blocked","startedAt":140},{"id":"task-agent-done","state":"idle","startedAt":150},{"id":"task-agent-idle","state":"idle","startedAt":160},{"id":"task-agent-unverifiable","state":"unverifiable","startedAt":170},{"id":"task-agent-no-start","state":"working","startedAt":0},{"id":"padded-agent","state":"working","startedAt":180}]"` + ) + }) + + it('derives the live and settled background lists exactly as today', () => { + const golden = projectAgentChildWorkLegacyBackgroundTasks( + PUBLISHED_TASKS.map(agentChildWorkProjectionCandidateFromBackgroundTask) + ) + expect(JSON.stringify(golden)).toMatchInlineSnapshot( + `"{"tasks":[{"id":"task-agent","kind":"agent","description":"Investigate","name":"researcher","state":"working","startedAt":100,"totalTokens":12,"stoppable":true},{"id":"task-agent-no-state","kind":"agent","name":"planner","startedAt":110,"stoppable":true},{"id":"task-agent-monitoring","kind":"agent","description":"Watch","state":"monitoring","startedAt":120,"stoppable":false},{"id":"task-agent-waiting","kind":"agent","state":"waiting","startedAt":130,"stoppable":true},{"id":"task-agent-blocked","kind":"agent","state":"blocked","startedAt":140,"stoppable":true},{"id":"task-agent-done","kind":"agent","state":"done","startedAt":150,"stoppable":true},{"id":"task-agent-idle","kind":"agent","state":"idle","startedAt":160,"stoppable":true},{"id":"task-agent-unverifiable","kind":"agent","state":"unverifiable","startedAt":170,"stoppable":true},{"id":"task-agent-no-start","kind":"agent","state":"working","startedAt":0,"stoppable":true},{"id":"padded-agent","kind":"agent","state":"working","startedAt":180,"stoppable":true},{"id":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","kind":"agent","state":"working","startedAt":190,"stoppable":true},{"id":"task-shell","kind":"command","description":"npm test","state":"working","startedAt":200,"stoppable":true},{"id":"task-monitor","kind":"monitor","description":"tail log","state":"monitoring","startedAt":210,"stoppable":true},{"id":"task-workflow","kind":"workflow","startedAt":220,"stoppable":true},{"id":"task-unknown","kind":"unknown","state":"blocked","startedAt":230,"stoppable":true}]}"` + ) + }) +}) diff --git a/src/shared/agent-status-child-work-legality.test.ts b/src/shared/agent-status-child-work-legality.test.ts new file mode 100644 index 00000000000..e555fbf3225 --- /dev/null +++ b/src/shared/agent-status-child-work-legality.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentChildWorkInput } from './agent-status-child-work' +import { createAgentChildWorkAdmission } from './agent-status-child-work-admission' +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' +) + +const OPERATION = { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: 15 } as const + +function live(overrides: Partial = {}): AgentChildWorkInput { + return { + childWorkId: 'child-1', + parent, + provider: 'claude', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 10, + observedAt: 20, + stoppable: true, + invocation: { invocationId: 'invocation-1', generation: 1 }, + provenance: { source: 'structured-session', producerId: 'journal-1' }, + ...overrides + } +} + +function settled(overrides: Partial = {}): AgentChildWorkInput { + return live({ + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 18, + ...overrides + }) +} + +// Literal matrix, not re-derived from the implementation: each cell names why it is illegal. +const ILLEGAL: [string, AgentChildWorkInput][] = [ + ['live work that says it is done', live({ state: 'done' })], + ['live work with an outcome', live({ outcome: 'succeeded' })], + ['live work with an unknown outcome', live({ outcome: 'unknown' })], + ['live work with a settle time', live({ settledAt: 15 })], + ['an agent storing monitoring', live({ state: 'monitoring' })], + ['a workflow storing monitoring', live({ kind: 'workflow', state: 'monitoring' })], + ['an unknown kind storing monitoring', live({ kind: 'unknown', state: 'monitoring' })], + ['settled work still working', settled({ state: 'working' })], + ['settled work monitoring', settled({ kind: 'command', state: 'monitoring' })], + ['settled work waiting', settled({ state: 'waiting' })], + ['settled work blocked', settled({ state: 'blocked' })], + ['settled work idle', settled({ state: 'idle' })], + ['settled work unverifiable', settled({ state: 'unverifiable' })], + ['settled before it was first seen', settled({ settledAt: 9 })], + ['settled after its newest evidence', settled({ settledAt: 21 })], + ['settled work with an operation', settled({ operation: OPERATION })], + ['idle work with an operation', live({ state: 'idle', operation: OPERATION })], + ['unverifiable work with an operation', live({ state: 'unverifiable', operation: OPERATION })], + [ + 'a monitoring shell with an operation', + live({ kind: 'command', state: 'monitoring', operation: OPERATION }) + ] +] + +const LEGAL: [string, AgentChildWorkInput][] = [ + ['working', live()], + ['waiting', live({ state: 'waiting' })], + ['blocked', live({ state: 'blocked' })], + ['idle (parked, resumable)', live({ state: 'idle' })], + ['unverifiable (host lost the evidence path)', live({ state: 'unverifiable' })], + ['a shell storing monitoring', live({ kind: 'command', state: 'monitoring' })], + ['a monitor storing monitoring', live({ kind: 'monitor', state: 'monitoring' })], + ['working with an operation', live({ operation: OPERATION })], + ['waiting with an operation', live({ state: 'waiting', operation: OPERATION })], + ['blocked with an operation', live({ state: 'blocked', operation: OPERATION })], + ['succeeded', settled()], + ['failed', settled({ outcome: 'failed' })], + ['cancelled', settled({ outcome: 'cancelled' })], + ['ended, outcome unknown', settled({ outcome: 'unknown' })], + ['settled at first sight', settled({ settledAt: 10 })], + ['settled at its newest evidence', settled({ settledAt: 20 })], + ['settled with its last message', settled({ lastMessage: 'All tests pass' })] +] + +describe('child-work legality matrix', () => { + it.each(ILLEGAL)('rejects %s', (_cell, value) => { + expect(parseAgentChildWorkInput(value)).toBeNull() + }) + + it.each(LEGAL)('admits %s unchanged', (_cell, value) => { + expect(parseAgentChildWorkInput(value)).toEqual(value) + }) +}) + +describe('child-work descriptive fields', () => { + it('round-trips owner, residency, operation, last message and settle time', () => { + const value = { + ...live({ + parentChildWorkId: 'child-owner', + residency: 'background', + operation: OPERATION, + lastMessage: 'Running the suite' + }), + revision: 3 + } + expect(parseAgentChildWorkRecord(value)).toEqual(value) + }) + + it('admits text already in its one-line form, at the caps', () => { + const value = live({ + name: 'x'.repeat(512), + description: 'Map the codebase\u00a0now', + operation: { ...OPERATION, toolName: 'x'.repeat(60), input: 'y'.repeat(160) }, + lastMessage: 'z'.repeat(512) + }) + expect(parseAgentChildWorkInput(value)).toEqual(value) + }) + + // Admission drops bad provider facts before they reach the codec, so each of these is a writer + // bug: the codec refuses the record rather than repairing it. + it.each([ + ['an operation that is not an object', { operation: 'Bash' }], + ['an operation with an unknown key', { operation: { ...OPERATION, raw: {} } }], + ['an operation with no tool name', { operation: { ...OPERATION, toolName: '' } }], + [ + 'a tool name longer than a status row carries', + { operation: { ...OPERATION, toolName: 'x'.repeat(61) } } + ], + ['a multi-line tool input', { operation: { ...OPERATION, input: 'a\nb' } }], + [ + 'a tool input longer than a status row carries', + { operation: { ...OPERATION, input: 'x'.repeat(161) } } + ], + ['an unknown operation basis', { operation: { ...OPERATION, basis: 'guessed' } }], + ['an operation observed before the child', { operation: { ...OPERATION, observedAt: 9 } }], + [ + 'an operation observed after the newest evidence', + { operation: { ...OPERATION, observedAt: 21 } } + ], + ['an unknown residency', { residency: 'detached' }], + ['a last message longer than 512', { lastMessage: 'x'.repeat(513) }], + ['a multi-line last message', { lastMessage: 'one\ntwo' }], + ['a last message ending in a space', { lastMessage: 'cut here ' }], + ['a last message with a line separator', { lastMessage: 'one\u2028two' }], + ['a name with a next-line control', { name: 'one\u0085two' }], + ['a name with a tab', { name: 'one\ttwo' }], + ['a description ending in a space', { description: 'cut here ' }], + ['a model longer than 512', { model: 'x'.repeat(513) }], + ['a negative token count', { totalTokens: -1 }], + ['an empty owner id', { parentChildWorkId: '' }], + ['a child that owns itself', { parentChildWorkId: 'child-1' }], + ['an unknown top-level key', { note: 'x' }], + [ + 'a settle time that is not a timestamp', + { membership: 'settled', state: 'done', outcome: 'failed', settledAt: -1 } + ], + [ + 'an outcome outside the vocabulary', + { membership: 'settled', state: 'done', outcome: 'crashed' } + ] + ])('rejects %s', (_case, field) => { + expect(parseAgentChildWorkInput({ ...live(), ...field })).toBeNull() + }) +}) + +describe('restored settled children written before outcome and settle time existed', () => { + // The shape the previous codec admitted: settled, `done`, with no outcome and no settle time. + const legacySettled = { + ...live({ state: 'done', membership: 'settled' }), + revision: 1 + } + + it('reads as an unknown ending at its newest evidence, never as success', () => { + expect(parseAgentChildWorkRecord(legacySettled)).toEqual({ + ...legacySettled, + outcome: 'unknown', + settledAt: 20 + }) + }) + + it('survives a store snapshot restore and carries its unknown outcome into resume history', () => { + const source = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(source.applyMutation({ parent: { subject: parent } })).not.toBeNull() + expect(source.applyMutation({ children: [live()] })).not.toBeNull() + const snapshot = source.getSnapshot() + const restored = createAgentStatusStore({ epoch: 'epoch-b', mode: 'authority' }) + expect( + restored.applySnapshot({ + ...snapshot, + children: snapshot.children.map((child) => ({ ...child, ...legacySettled })) + }) + ).toBe(true) + expect(restored.getChild('child-1')).toMatchObject({ outcome: 'unknown', settledAt: 20 }) + + const admission = createAgentChildWorkAdmission(restored, { mintChildWorkId: vi.fn() }) + expect( + admission.resume({ + parent, + provider: 'claude', + childWorkId: 'child-1', + expectedFence: { invocationId: 'invocation-1', generation: 1 }, + nextFence: { invocationId: 'invocation-2', generation: 2 }, + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], + kind: 'agent', + state: 'working', + membership: 'live', + observedAt: 30, + stoppable: true, + provenance: { source: 'restore', producerId: 'roster' } + }) + ).toMatchObject({ accepted: true }) + expect(restored.getChild('child-1')?.previousInvocations).toEqual([ + { + fence: { invocationId: 'invocation-1', generation: 1 }, + outcome: 'unknown', + settledAt: 20 + } + ]) + }) +}) diff --git a/src/shared/agent-status-child-work-legality.ts b/src/shared/agent-status-child-work-legality.ts new file mode 100644 index 00000000000..f6e514d35bb --- /dev/null +++ b/src/shared/agent-status-child-work-legality.ts @@ -0,0 +1,56 @@ +import type { + AgentChildWorkInput, + AgentChildWorkKind, + AgentChildWorkMembership, + AgentChildWorkState +} from './agent-status-child-work' + +/** A child says what it is doing only while it is live and doing it. */ +export function agentChildWorkAllowsOperation( + membership: AgentChildWorkMembership, + state: AgentChildWorkState +): boolean { + return ( + membership === 'live' && (state === 'working' || state === 'waiting' || state === 'blocked') + ) +} + +/** Only a shell or a monitor stores `monitoring`; an agent's is derived from the work it owns. */ +function storesMonitoring(kind: AgentChildWorkKind): boolean { + return kind === 'command' || kind === 'monitor' +} + +/** The membership x state matrix every record satisfies: live work has no outcome and is not + * `done`; settled work is `done` with an outcome and the host time it settled. */ +export function isAgentChildWorkLifecycleLegal( + record: Pick< + AgentChildWorkInput, + | 'kind' + | 'state' + | 'membership' + | 'outcome' + | 'settledAt' + | 'operation' + | 'firstObservedAt' + | 'observedAt' + > +): boolean { + if (record.operation && !agentChildWorkAllowsOperation(record.membership, record.state)) { + return false + } + if (record.membership === 'live') { + return ( + record.state !== 'done' && + (record.state !== 'monitoring' || storesMonitoring(record.kind)) && + record.outcome === undefined && + record.settledAt === undefined + ) + } + return ( + record.state === 'done' && + record.outcome !== undefined && + record.settledAt !== undefined && + record.settledAt >= record.firstObservedAt && + record.settledAt <= record.observedAt + ) +} diff --git a/src/shared/agent-status-child-work-projection.test.ts b/src/shared/agent-status-child-work-projection.test.ts index 31680b86f16..e9e3d87e8cf 100644 --- a/src/shared/agent-status-child-work-projection.test.ts +++ b/src/shared/agent-status-child-work-projection.test.ts @@ -11,21 +11,19 @@ import type { AgentChildWorkState } from './agent-status-child-work' function candidate( providerId: string, - overrides: Partial = {} + overrides: Partial = {} ): AgentChildWorkLegacyProjectionCandidate { return { providerId, - child: { - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 123, - description: 'Investigate', - agentType: 'researcher', - model: 'model-a', - stoppable: true, - ...overrides - } + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 123, + description: 'Investigate', + agentType: 'researcher', + model: 'model-a', + stoppable: true, + ...overrides } } @@ -140,16 +138,14 @@ describe('agentChildWorkProjectionCandidateFromBackgroundTask', () => { }) ).toEqual({ providerId: 'task-1', - child: { - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 55, - name: 'researcher', - agentType: 'researcher', - description: 'Investigate', - stoppable: false - } + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 55, + name: 'researcher', + agentType: 'researcher', + description: 'Investigate', + stoppable: false }) }) @@ -160,10 +156,10 @@ describe('agentChildWorkProjectionCandidateFromBackgroundTask', () => { name: '', description: '' }) - expect(projected.child).not.toHaveProperty('name') - expect(projected.child).not.toHaveProperty('agentType') - expect(projected.child).not.toHaveProperty('description') - expect(projected.child.stoppable).toBe(true) + expect(projected).not.toHaveProperty('name') + expect(projected).not.toHaveProperty('agentType') + expect(projected).not.toHaveProperty('description') + expect(projected.stoppable).toBe(true) }) }) diff --git a/src/shared/agent-status-child-work-projection.ts b/src/shared/agent-status-child-work-projection.ts index 937a4391cef..9d4e26230ca 100644 --- a/src/shared/agent-status-child-work-projection.ts +++ b/src/shared/agent-status-child-work-projection.ts @@ -1,29 +1,40 @@ import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types' import { isAgentChildWorkKind } from './agent-status-child-work-liveness' -import type { - AgentChildWorkKind, - AgentChildWorkMembership, - AgentChildWorkState -} from './agent-status-child-work' +import type { AgentChildWorkOutcome, AgentChildWorkState } from './agent-status-child-work' +import type { AgentChildWorkView } from './agent-status-child-work-view' 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 - } +/** Anything a legacy wire shape can be derived from. Every `AgentChildWorkView` is one, so the + * old and new shapes cannot disagree; so is a published background task (its state optional, + * because an old host sends none). */ +export type AgentChildWorkLegacyProjectionCandidate = Pick< + AgentChildWorkView, + 'kind' | 'membership' | 'firstObservedAt' | 'stoppable' +> & + Partial< + Pick< + AgentChildWorkView, + | 'providerId' + | 'state' + | 'outcome' + | 'name' + | 'description' + | 'agentType' + | 'model' + | 'totalTokens' + > + > + +// The run state today's hosts publish for a settled task, so an old strip reads a settled view +// exactly as it reads a settled task now (an unreadable terminal status settles as `done`). +const LEGACY_SETTLED_RUN_STATE: Record = { + succeeded: 'done', + failed: 'blocked', + cancelled: 'idle', + unknown: 'done' } function legacyProviderId(value: unknown): string | null { @@ -64,18 +75,16 @@ export function agentChildWorkProjectionCandidateFromBackgroundTask( ): AgentChildWorkLegacyProjectionCandidate { return { providerId: task.id, - child: { - kind: task.kind, - ...(task.state !== undefined ? { state: task.state } : {}), - membership: 'live', - firstObservedAt: task.startedAt ?? 0, - // Truthy, not present: an empty label carries no identity and would beat the - // `description ?? agentType ?? 'unknown'` fallbacks every child-row reader relies on. - ...(task.name ? { name: task.name, agentType: task.name } : {}), - ...(task.description ? { description: task.description } : {}), - ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}), - stoppable: task.stoppable ?? true - } + kind: task.kind, + ...(task.state !== undefined ? { state: task.state } : {}), + membership: 'live', + firstObservedAt: task.startedAt ?? 0, + // Truthy, not present: an empty label carries no identity and would beat the + // `description ?? agentType ?? 'unknown'` fallbacks every child-row reader relies on. + ...(task.name ? { name: task.name, agentType: task.name } : {}), + ...(task.description ? { description: task.description } : {}), + ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}), + stoppable: task.stoppable ?? true } } @@ -84,28 +93,27 @@ export function projectAgentChildWorkLegacySubagents( ): AgentSubagentSnapshot[] | undefined { const projected: AgentSubagentSnapshot[] = [] for (const candidate of candidates) { - if (!isAgentChildWorkKind(candidate.child.kind)) { + // The legacy roster lists live subagents only; a settled child was never published there. + if (!isAgentChildWorkKind(candidate.kind) || candidate.membership !== 'live') { continue } const id = legacyProviderId(candidate.providerId) - const state = legacySubagentState(candidate.child.state) + const state = legacySubagentState(candidate.state) if ( !id || !state || - !Number.isFinite(candidate.child.firstObservedAt) || - candidate.child.firstObservedAt < 0 + !Number.isFinite(candidate.firstObservedAt) || + candidate.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 } - : {}) + startedAt: candidate.firstObservedAt, + ...(candidate.agentType !== undefined ? { agentType: candidate.agentType } : {}), + ...(candidate.model !== undefined ? { model: candidate.model } : {}), + ...(candidate.description !== undefined ? { description: candidate.description } : {}) }) if (projected.length === AGENT_STATUS_MAX_SUBAGENTS) { break @@ -123,26 +131,22 @@ function projectBackgroundTask( candidate: AgentChildWorkLegacyProjectionCandidate ): AgentSessionBackgroundTask | null { const id = backgroundProviderId(candidate.providerId) - if ( - !id || - !Number.isFinite(candidate.child.firstObservedAt) || - candidate.child.firstObservedAt < 0 - ) { + if (!id || !Number.isFinite(candidate.firstObservedAt) || candidate.firstObservedAt < 0) { return null } + const state = + candidate.membership === 'settled' && candidate.outcome !== undefined + ? LEGACY_SETTLED_RUN_STATE[candidate.outcome] + : candidate.state 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 + kind: candidate.kind, + ...(candidate.description !== undefined ? { description: candidate.description } : {}), + ...(candidate.name !== undefined ? { name: candidate.name } : {}), + ...(state !== undefined ? { state } : {}), + startedAt: candidate.firstObservedAt, + ...(candidate.totalTokens !== undefined ? { totalTokens: candidate.totalTokens } : {}), + stoppable: candidate.stoppable } } @@ -156,7 +160,7 @@ export function projectAgentChildWorkLegacyBackgroundTasks( if (!projected) { continue } - if (candidate.child.membership === 'live') { + if (candidate.membership === 'live') { tasks.push(projected) } else { settledTasks.push(projected) diff --git a/src/shared/agent-status-child-work-resume.ts b/src/shared/agent-status-child-work-resume.ts index b8b87efccf5..4b3cbf8401e 100644 --- a/src/shared/agent-status-child-work-resume.ts +++ b/src/shared/agent-status-child-work-resume.ts @@ -5,6 +5,7 @@ import { } from './agent-status-child-work' import { agentChildWorkAliasesForChild, + agentChildWorkSettledAt, buildAgentChildWork, buildAgentChildWorkAliases, commitAgentChildWork, @@ -63,15 +64,19 @@ export function resumeAgentChildWork( { fence: child.invocation, ...(child.outcome !== undefined ? { outcome: child.outcome } : {}), - ...(child.membership === 'settled' ? { settledAt: child.observedAt } : {}) + ...(child.settledAt !== undefined ? { settledAt: child.settledAt } : {}) } ].slice(-AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX) const resumed = buildAgentChildWork( request, - child.childWorkId, - child.firstObservedAt, - nextFence, - previousInvocations + { + childWorkId: child.childWorkId, + firstObservedAt: child.firstObservedAt, + invocation: nextFence, + previousInvocations, + settledAt: agentChildWorkSettledAt(request) + }, + child ) const retainedFences = [nextFence, ...previousInvocations.map((entry) => entry.fence)] const removeAliases = agentChildWorkAliasesForChild(store, child.childWorkId) diff --git a/src/shared/agent-status-child-work-value-guards.ts b/src/shared/agent-status-child-work-value-guards.ts new file mode 100644 index 00000000000..1206981dbfd --- /dev/null +++ b/src/shared/agent-status-child-work-value-guards.ts @@ -0,0 +1,77 @@ +import { normalizeOptionalField } from './agent-status-field-normalization' + +export const AGENT_CHILD_WORK_ID_MAX_LENGTH = 256 + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function hasOnlyKeys( + record: Record, + 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)) + ) +} + +/** A C0 control or DEL: never part of a record's one-line text. */ +function isControlCharCode(code: number): boolean { + return code <= 0x1f || code === 0x7f +} + +/** A control character, or a code point a renderer draws as a line break (NEL, LS, PS). */ +function breaksOneLineText(code: number): boolean { + return isControlCharCode(code) || code === 0x85 || code === 0x2028 || code === 0x2029 +} + +/** The one text normalizer for a child record: the status-row preview, with anything that would + * break a one-line row folded to a space and the cut's edges trimmed. */ +export function normalizeChildWorkText(raw: unknown, maxLength: number): string | undefined { + const preview = normalizeOptionalField(raw, maxLength) + if (preview === undefined) { + return undefined + } + let text = '' + for (let index = 0; index < preview.length; index += 1) { + text += breaksOneLineText(preview.charCodeAt(index)) ? ' ' : preview[index] + } + return text.trim() || undefined +} + +/** Exactly the normalizer's image, so the codec accepts every value admission can store. */ +export function isChildWorkText(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && normalizeChildWorkText(value, maxLength) === value +} + +export function isChildWorkTokenCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +/** An id: nonempty, trimmed, no control characters, within `maxLength`. */ +export function isBoundedString( + value: unknown, + maxLength = AGENT_CHILD_WORK_ID_MAX_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) { + if (isControlCharCode(value.charCodeAt(index))) { + return false + } + } + return true +} + +export function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} diff --git a/src/shared/agent-status-child-work-view.test.ts b/src/shared/agent-status-child-work-view.test.ts new file mode 100644 index 00000000000..9c79a545183 --- /dev/null +++ b/src/shared/agent-status-child-work-view.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it, vi } from 'vitest' +import { foldAgentLeadStatus } from './agent-lead-status-fold' +import type { AgentChildWorkInput, AgentChildWorkOutcome } from './agent-status-child-work' +import { + createAgentChildWorkAdmission, + type AgentChildWorkAnnounceRequest +} from './agent-status-child-work-admission' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness +} from './agent-status-child-work-liveness' +import { + projectAgentChildWorkLegacyBackgroundTasks, + projectAgentChildWorkLegacySubagents +} from './agent-status-child-work-projection' +import { + agentChildWorkOwnedLiveness, + deriveAgentChildDisplayState, + projectAgentChildWorkViews, + type AgentChildWorkView, + type AgentChildWorkViewAlias +} from './agent-status-child-work-view' +import { createAgentStatusStore } from './agent-status-store' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +const SESSION_ID = 'session_11111111-1111-4111-8111-111111111111' +const scope = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' +} as const +const parent = makeStructuredAgentStatusSubject(scope, SESSION_ID) +const otherParent = makeStructuredAgentStatusSubject( + { ...scope, workspaceId: 'folder-2' }, + SESSION_ID +) +const FENCE = { invocationId: 'invocation-1', generation: 1 } + +function record(childWorkId: string, overrides: Partial = {}) { + return { + childWorkId, + parent, + provider: 'claude', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 10, + observedAt: 20, + stoppable: true, + invocation: FENCE, + provenance: { source: 'structured-session', producerId: 'journal-1' }, + ...overrides + } satisfies AgentChildWorkInput +} + +function alias( + childWorkId: string, + aliasKind: AgentChildWorkViewAlias['aliasKind'], + value: string, + fence = FENCE +): AgentChildWorkViewAlias { + return { childWorkId, aliasKind, alias: value, fence } +} + +describe('projectAgentChildWorkViews', () => { + it('carries what a surface reads and drops host bookkeeping', () => { + const [view] = projectAgentChildWorkViews( + [ + record('child-1', { + name: 'researcher', + description: 'Investigate', + agentType: 'researcher', + model: 'model-a', + state: 'done', + membership: 'settled', + outcome: 'failed', + settledAt: 18, + lastMessage: 'Exit code 1', + totalTokens: 42, + residency: 'background', + providerTiming: { startedAt: 1 }, + previousInvocations: [{ fence: { invocationId: 'invocation-0', generation: 0 } }] + }) + ], + [alias('child-1', 'task_id', 'task-1')] + ) + expect(view).toEqual({ + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + name: 'researcher', + description: 'Investigate', + agentType: 'researcher', + model: 'model-a', + state: 'done', + membership: 'settled', + outcome: 'failed', + lastMessage: 'Exit code 1', + firstObservedAt: 10, + observedAt: 20, + settledAt: 18, + totalTokens: 42, + stoppable: true, + invocation: FENCE + }) + }) + + it('names a child by its stable provider handle for the current invocation only', () => { + const old = { invocationId: 'invocation-0', generation: 0 } + const views = projectAgentChildWorkViews( + [record('claude'), record('codex'), record('provisional'), record('no-handle')], + [ + alias('claude', 'tool_use_id', 'toolu_2'), + alias('claude', 'task_id', 'task-1'), + alias('codex', 'tool_use_id', 'call-1'), + alias('codex', 'thread_id', 'thread-child'), + alias('provisional', 'task_id', 'task-old', old), + alias('provisional', 'tool_use_id', 'toolu_1'), + alias('no-handle', 'task_id', 'task-stale', old) + ] + ) + expect(views.map((view) => view.providerId)).toEqual([ + 'task-1', + 'thread-child', + 'toolu_1', + undefined + ]) + expect(views[3]).not.toHaveProperty('providerId') + }) + + it('keeps an owner only when it is present, in the same session, and not on a cycle', () => { + const views = projectAgentChildWorkViews( + [ + record('owner'), + record('shell', { kind: 'command', parentChildWorkId: 'owner' }), + record('orphan', { kind: 'command', parentChildWorkId: 'removed' }), + record('elsewhere', { parent: otherParent }), + record('cross', { kind: 'command', parentChildWorkId: 'elsewhere' }), + record('cycle-a', { parentChildWorkId: 'cycle-b' }), + record('cycle-b', { parentChildWorkId: 'cycle-a' }), + record('into-cycle', { kind: 'command', parentChildWorkId: 'cycle-a' }) + ], + [] + ) + expect(Object.fromEntries(views.map((view) => [view.id, view.parentChildWorkId]))).toEqual({ + owner: undefined, + shell: 'owner', + orphan: undefined, + elsewhere: undefined, + cross: undefined, + 'cycle-a': undefined, + 'cycle-b': undefined, + 'into-cycle': 'cycle-a' + }) + }) +}) + +type DisplayCase = [ + label: string, + view: Pick, + owned: AgentChildWorkLiveness, + expected: ReturnType +] + +const liveAs = (state: AgentChildWorkView['state']) => ({ state, membership: 'live' }) as const +const settledAs = (outcome: AgentChildWorkOutcome) => + ({ state: 'done', membership: 'settled', outcome }) as const + +// Literal expectations (the product table), independent of the implementation. +const DISPLAY: DisplayCase[] = [ + ['working', liveAs('working'), null, 'working'], + ['working, owning a live shell', liveAs('working'), 'monitoring', 'working'], + ['waiting, owning a live agent', liveAs('waiting'), 'working', 'waiting'], + ['blocked, owning a live shell', liveAs('blocked'), 'monitoring', 'blocked'], + ['idle', liveAs('idle'), null, 'idle'], + ['idle, owning a live shell', liveAs('idle'), 'monitoring', 'monitoring'], + ['idle, owning a live agent', liveAs('idle'), 'working', 'working'], + ['unverifiable, even owning a live shell', liveAs('unverifiable'), 'monitoring', 'unverifiable'], + ['a shell that stores monitoring', liveAs('monitoring'), null, 'monitoring'], + ['finished', settledAs('succeeded'), null, 'done'], + ['finished, owning a live shell', settledAs('succeeded'), 'monitoring', 'monitoring'], + ['finished, owning a live agent', settledAs('succeeded'), 'working', 'working'], + ['failed', settledAs('failed'), null, 'failed'], + ['cancelled', settledAs('cancelled'), null, 'interrupted'], + ['cancelled, its shell still running', settledAs('cancelled'), 'monitoring', 'monitoring'], + ['ended, outcome unknown', settledAs('unknown'), null, 'idle'] +] + +describe('deriveAgentChildDisplayState', () => { + it.each(DISPLAY)('%s', (_label, view, owned, expected) => { + expect(deriveAgentChildDisplayState(view, owned)).toBe(expected) + }) + + it.each([ + ['idle', liveAs('idle')], + ['finished', settledAs('succeeded')] + ])( + 'gives a %s child with a live shell what the parent-row fold gives a CLI agent', + (_l, view) => { + // The same owned work, folded for a CLI agent whose own turn is over. + const ownedShell = [{ kind: 'command', state: 'working' }] as const + // A non-literal input, as the view passes it, so the fold's input can lose a field. + const foldInput = { + leadState: 'done', + interrupted: false, + childWorkLiveness: agentChildWorkLiveness(ownedShell) + } as const + expect(foldAgentLeadStatus(foldInput)).toEqual({ + stateName: 'working', + workingMode: 'monitoring' + }) + expect(deriveAgentChildDisplayState(view, agentChildWorkLiveness(ownedShell))).toBe( + 'monitoring' + ) + } + ) +}) + +describe('agentChildWorkOwnedLiveness', () => { + const views = projectAgentChildWorkViews( + [ + record('owner', { + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 20 + }), + record('grandchild', { parentChildWorkId: 'owner', state: 'idle' }), + record('grandchild-shell', { kind: 'command', parentChildWorkId: 'grandchild' }), + record('settled-shell', { + kind: 'command', + parentChildWorkId: 'owner', + state: 'done', + membership: 'settled', + outcome: 'cancelled', + settledAt: 20 + }), + record('unowned-agent') + ], + [] + ) + + it('reads live work at any depth beneath the child, and nothing else', () => { + expect(agentChildWorkOwnedLiveness(views, 'owner')).toBe('monitoring') + expect(agentChildWorkOwnedLiveness(views, 'grandchild')).toBe('monitoring') + expect(agentChildWorkOwnedLiveness(views, 'unowned-agent')).toBeNull() + }) + + it('terminates on an ownership cycle a caller built by hand', () => { + const cyclic = [ + { id: 'a', kind: 'agent', state: 'working', membership: 'live', parentChildWorkId: 'b' }, + { id: 'b', kind: 'command', state: 'working', membership: 'live', parentChildWorkId: 'a' } + ] as const + // The walk stops on returning to `a`: only the shell beneath it counts. + expect(agentChildWorkOwnedLiveness(cyclic, 'a')).toBe('monitoring') + expect(agentChildWorkOwnedLiveness(cyclic, 'b')).toBe('working') + }) +}) + +describe('legacy shapes derived from views', () => { + it('publish exactly what today’s wire carries for the same children', () => { + const views = projectAgentChildWorkViews( + [ + record('agent', { + name: 'researcher', + agentType: 'researcher', + description: 'Investigate', + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: 15 }, + lastMessage: 'Running tests' + }), + record('failed-agent', { + state: 'done', + membership: 'settled', + outcome: 'failed', + settledAt: 20 + }), + record('shell', { kind: 'command', parentChildWorkId: 'agent', totalTokens: 3 }) + ], + [ + alias('agent', 'task_id', 'task-agent'), + alias('failed-agent', 'task_id', 'task-failed'), + alias('shell', 'task_id', 'task-shell') + ] + ) + expect(projectAgentChildWorkLegacySubagents(views)).toEqual([ + { + id: 'task-agent', + state: 'working', + startedAt: 10, + agentType: 'researcher', + description: 'Investigate' + } + ]) + expect(projectAgentChildWorkLegacyBackgroundTasks(views)).toEqual({ + tasks: [ + { + id: 'task-agent', + kind: 'agent', + description: 'Investigate', + name: 'researcher', + state: 'working', + startedAt: 10, + stoppable: true + }, + { + id: 'task-shell', + kind: 'command', + state: 'working', + startedAt: 10, + totalTokens: 3, + stoppable: true + } + ], + settledTasks: [ + { id: 'task-failed', kind: 'agent', state: 'blocked', startedAt: 10, stoppable: true } + ] + }) + }) + + it.each([ + ['succeeded', 'done'], + ['failed', 'blocked'], + ['cancelled', 'idle'], + ['unknown', 'done'] + ] as const)( + 'reads a settled %s child as the %s task a host publishes today', + (outcome, state) => { + const views = projectAgentChildWorkViews( + [record('child', { state: 'done', membership: 'settled', outcome, settledAt: 20 })], + [alias('child', 'task_id', 'task-1')] + ) + expect(projectAgentChildWorkLegacyBackgroundTasks(views).settledTasks?.[0]?.state).toBe(state) + } + ) +}) + +describe('one child and the shell it launched, end to end', () => { + function observation(overrides: Partial) { + return { + parent, + provider: 'claude', + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-agent' }], + fence: FENCE, + lifetime: 'current', + kind: 'agent', + state: 'working', + membership: 'live', + observedAt: 10, + stoppable: true, + provenance: { source: 'structured-session', producerId: 'journal-1' }, + ...overrides + } satisfies AgentChildWorkAnnounceRequest + } + + it('reads the finished child as monitoring while its shell runs, then as done', () => { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let sequence = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: vi.fn(() => `child-${++sequence}`) + }) + const shell = observation({ + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-shell' }], + kind: 'command', + parentChildWorkId: 'child-1', + observedAt: 12 + }) + admission.announce(observation({})) + admission.announce(shell) + admission.announce( + observation({ state: 'done', membership: 'settled', outcome: 'succeeded', observedAt: 20 }) + ) + const display = () => { + const children = store.getChildren(parent) + const views = projectAgentChildWorkViews( + children, + children.flatMap((child) => store.getAliasesForChild(child.childWorkId)) + ) + const agent = views.find((view) => view.providerId === 'task-agent') + return ( + agent && deriveAgentChildDisplayState(agent, agentChildWorkOwnedLiveness(views, agent.id)) + ) + } + + expect(display()).toBe('monitoring') + admission.announce({ + ...shell, + state: 'done', + membership: 'settled', + outcome: 'succeeded', + observedAt: 30 + }) + expect(display()).toBe('done') + }) +}) diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts new file mode 100644 index 00000000000..80324d854e4 --- /dev/null +++ b/src/shared/agent-status-child-work-view.ts @@ -0,0 +1,239 @@ +import { foldAgentLeadStatus } from './agent-lead-status-fold' +import { + AGENT_CHILD_WORK_ALIAS_KINDS, + type AgentChildWorkAliasInput, + type AgentChildWorkAliasKind +} from './agent-status-child-work-alias' +import { + agentChildWorkFencesEqual, + type AgentChildWorkId, + type AgentChildWorkInput, + type AgentChildWorkInvocationFence, + type AgentChildWorkKind, + type AgentChildWorkMembership, + type AgentChildWorkOperation, + type AgentChildWorkOutcome, + type AgentChildWorkState +} from './agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness +} from './agent-status-child-work-liveness' +import { agentStatusSubjectsEqual } from './agent-status-subject' +import type { AgentStatusState } from './agent-status-types' + +/** What a surface reads about one child: a read-only projection of the host's record. + * Host bookkeeping (residency, invocation history, provenance, aliases) never travels. A view + * from another build is decoded permissively or behind a capability, never by the record codec. */ +export type AgentChildWorkView = { + id: AgentChildWorkId + /** The id today's wire names this child by (`tasks[].id`, `subagents[].id`). Absent when the + * host holds no provider handle for the current invocation; legacy shapes then omit the row. */ + providerId?: string + kind: AgentChildWorkKind + name?: string + description?: string + agentType?: string + model?: string + state: AgentChildWorkState + membership: AgentChildWorkMembership + outcome?: AgentChildWorkOutcome + operation?: AgentChildWorkOperation + lastMessage?: string + /** Present only when the owner is in the same projection; otherwise the main agent owns it. */ + parentChildWorkId?: AgentChildWorkId + firstObservedAt: number + observedAt: number + settledAt?: number + totalTokens?: number + stoppable: boolean + invocation: AgentChildWorkInvocationFence +} + +export type AgentChildWorkViewAlias = Pick< + AgentChildWorkAliasInput, + 'childWorkId' | 'aliasKind' | 'alias' | 'fence' +> + +// Stable handles before per-call ones: a spawn's tool id changes on every resume. Keyed by kind so +// a new alias kind cannot compile without a rank (an unranked kind would lose its row's providerId). +const PROVIDER_ID_ALIAS_RANK: Record = { + task_id: 0, + thread_id: 1, + tool_use_id: 2 +} +const PROVIDER_ID_ALIAS_ORDER = [...AGENT_CHILD_WORK_ALIAS_KINDS].sort( + (left, right) => PROVIDER_ID_ALIAS_RANK[left] - PROVIDER_ID_ALIAS_RANK[right] +) + +function providerIdFor( + record: AgentChildWorkInput, + aliases: readonly AgentChildWorkViewAlias[] +): string | undefined { + const current = aliases.filter((alias) => + agentChildWorkFencesEqual(alias.fence, record.invocation) + ) + for (const kind of PROVIDER_ID_ALIAS_ORDER) { + const match = current.find((alias) => alias.aliasKind === kind) + if (match) { + return match.alias + } + } + return undefined +} + +// Not `Map.groupBy`: the relay runs this core on Node 18, which lacks it. +function groupedBy(items: readonly T[], keyOf: (item: T) => K): Map { + const groups = new Map() + for (const item of items) { + const key = keyOf(item) + const group = groups.get(key) + if (group) { + group.push(item) + } else { + groups.set(key, [item]) + } + } + return groups +} + +function isOnOwnershipCycle( + start: AgentChildWorkInput, + byId: ReadonlyMap +): boolean { + const seen = new Set() + let cursor = start.parentChildWorkId + while (cursor !== undefined && !seen.has(cursor)) { + if (cursor === start.childWorkId) { + return true + } + seen.add(cursor) + cursor = byId.get(cursor)?.parentChildWorkId + } + return false +} + +/** An owner that is gone, belongs to another session, or closes a cycle does not own the work. */ +function resolvedOwner( + record: AgentChildWorkInput, + byId: ReadonlyMap +): AgentChildWorkId | undefined { + const owner = + record.parentChildWorkId === undefined ? undefined : byId.get(record.parentChildWorkId) + return owner && + agentStatusSubjectsEqual(owner.parent, record.parent) && + !isOnOwnershipCycle(record, byId) + ? owner.childWorkId + : undefined +} + +/** The one record-to-view projection; every surface and every legacy shape starts here. */ +export function projectAgentChildWorkViews( + records: readonly AgentChildWorkInput[], + aliases: readonly AgentChildWorkViewAlias[] +): AgentChildWorkView[] { + const byId = new Map(records.map((record) => [record.childWorkId, record])) + const aliasesByChild = groupedBy(aliases, (alias) => alias.childWorkId) + return records.map((record) => { + const providerId = providerIdFor(record, aliasesByChild.get(record.childWorkId) ?? []) + const owner = resolvedOwner(record, byId) + return { + id: record.childWorkId, + ...(providerId !== undefined ? { providerId } : {}), + kind: record.kind, + ...(record.name !== undefined ? { name: record.name } : {}), + ...(record.description !== undefined ? { description: record.description } : {}), + ...(record.agentType !== undefined ? { agentType: record.agentType } : {}), + ...(record.model !== undefined ? { model: record.model } : {}), + state: record.state, + membership: record.membership, + ...(record.outcome !== undefined ? { outcome: record.outcome } : {}), + ...(record.operation !== undefined ? { operation: { ...record.operation } } : {}), + ...(record.lastMessage !== undefined ? { lastMessage: record.lastMessage } : {}), + ...(owner !== undefined ? { parentChildWorkId: owner } : {}), + firstObservedAt: record.firstObservedAt, + observedAt: record.observedAt, + ...(record.settledAt !== undefined ? { settledAt: record.settledAt } : {}), + ...(record.totalTokens !== undefined ? { totalTokens: record.totalTokens } : {}), + stoppable: record.stoppable, + invocation: { ...record.invocation } + } + }) +} + +type AgentChildWorkOwnershipView = Pick< + AgentChildWorkView, + 'id' | 'kind' | 'state' | 'membership' | 'parentChildWorkId' +> + +/** Liveness of all live work beneath a child, at any depth — the same input a parent row folds. */ +export function agentChildWorkOwnedLiveness( + views: readonly AgentChildWorkOwnershipView[], + ownerId: AgentChildWorkId +): AgentChildWorkLiveness { + const owned = groupedBy(views, (view) => view.parentChildWorkId) + const seen = new Set([ownerId]) + const frontier = [ownerId] + const liveDescendants: AgentChildWorkOwnershipView[] = [] + for (let owner = frontier.pop(); owner !== undefined; owner = frontier.pop()) { + for (const view of owned.get(owner) ?? []) { + if (!seen.has(view.id)) { + seen.add(view.id) + frontier.push(view.id) + if (view.membership === 'live') { + liveDescendants.push(view) + } + } + } + } + return agentChildWorkLiveness(liveDescendants) +} + +/** The dot a child row renders; every value is an `AgentStateDot` state. */ +export type AgentChildDisplayState = + | 'working' + | 'monitoring' + | 'waiting' + | 'blocked' + | 'done' + | 'failed' + | 'interrupted' + | 'idle' + | 'unverifiable' + +const SETTLED_DISPLAY_STATE: Record = { + succeeded: 'done', + failed: 'failed', + cancelled: 'interrupted', + // Neutral: an ending the lane cannot classify asserts nothing. + unknown: 'idle' +} + +/** + * A child's display state, through the same fold that decides a parent row's: work that is idle + * or finished enters it as `done`, so a live shell the child owns reads `monitoring` exactly as it + * would under a CLI agent. `unverifiable` is a freshness verdict and bypasses the fold. + */ +export function deriveAgentChildDisplayState( + view: Pick, + ownedLiveness: AgentChildWorkLiveness +): AgentChildDisplayState { + if (view.state === 'unverifiable') { + return 'unverifiable' + } + // Stored only by a shell or a monitor, and neither owns work. + if (view.state === 'monitoring') { + return 'monitoring' + } + const leadState: AgentStatusState = + view.membership === 'settled' || view.state === 'done' || view.state === 'idle' + ? 'done' + : view.state + // A child's cancel never hides the work it left running. + const foldInput = { leadState, childWorkLiveness: ownedLiveness, interrupted: false } + const folded = foldAgentLeadStatus(foldInput) + if (folded.stateName !== 'done') { + return folded.workingMode ?? folded.stateName + } + return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown'] +} diff --git a/src/shared/agent-status-child-work.test.ts b/src/shared/agent-status-child-work.test.ts index 3d5d933615c..8b11888ae56 100644 --- a/src/shared/agent-status-child-work.test.ts +++ b/src/shared/agent-status-child-work.test.ts @@ -58,7 +58,11 @@ describe('AgentChildWorkRecord', () => { const children = AGENT_CHILD_WORK_STATES.map((state, index) => child({ childWorkId: `child-${index}`, - kind: AGENT_CHILD_WORK_KINDS[index % AGENT_CHILD_WORK_KINDS.length], + // Only a shell or a monitor may store `monitoring`. + kind: + state === 'monitoring' + ? 'monitor' + : AGENT_CHILD_WORK_KINDS[index % AGENT_CHILD_WORK_KINDS.length], state, membership: state === 'done' ? 'settled' : 'live', ...(state === 'done' ? { outcome: 'failed' } : {}), @@ -71,7 +75,7 @@ describe('AgentChildWorkRecord', () => { const snapshot = store.getSnapshot() expect(snapshot.children.map((item) => item.kind)).toEqual([ 'agent', - 'workflow', + 'monitor', 'command', 'monitor', 'unknown', diff --git a/src/shared/agent-status-child-work.ts b/src/shared/agent-status-child-work.ts index c82d25b42c8..c537bae4acc 100644 --- a/src/shared/agent-status-child-work.ts +++ b/src/shared/agent-status-child-work.ts @@ -19,12 +19,31 @@ export const AGENT_CHILD_WORK_STATES = [ 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 const AGENT_CHILD_WORK_RESIDENCIES = ['foreground', 'background'] as const +export const AGENT_CHILD_WORK_OPERATION_BASES = ['open', 'reported'] as const +export const AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH = 512 +export const AGENT_CHILD_WORK_LABEL_MAX_LENGTH = 512 +export const AGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH = 8_000 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] +/** Whether the provider asserted the child may outlive the turn that launched it. */ +export type AgentChildWorkResidency = (typeof AGENT_CHILD_WORK_RESIDENCIES)[number] +/** `open`: a start edge was seen and no end yet. `reported`: the provider's latest heartbeat + * named it; no end edge will come, so the next report or the settlement replaces it. */ +export type AgentChildWorkOperationBasis = (typeof AGENT_CHILD_WORK_OPERATION_BASES)[number] + +/** What the child is doing now, in the vocabulary a hook-reported row uses for its own tool. */ +export type AgentChildWorkOperation = { + toolName: string + /** One-line preview, the same text a status row carries as `toolInput`. */ + input?: string + basis: AgentChildWorkOperationBasis + observedAt: number +} export type AgentChildWorkInvocationFence = { invocationId: string @@ -61,8 +80,20 @@ export type AgentChildWorkInput = { model?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming + /** The child that owns this invocation (a nested agent's spawner, or the agent that launched a + * shell). Absent means the session's main agent owns it. */ + parentChildWorkId?: AgentChildWorkId + /** Host-only: settlement consults it; never projected to a view. */ + residency?: AgentChildWorkResidency + /** Only while live and working, waiting or blocked. */ + operation?: AgentChildWorkOperation + /** Newest thing the child said; `outcome` says whether it is a result or an error. */ + lastMessage?: string firstObservedAt: number + /** The child's own evidence clock: the last time the host admitted evidence for it. */ observedAt: number + /** Host time the current invocation settled. Stamped by admission, never by a producer. */ + settledAt?: number stoppable: boolean invocation: AgentChildWorkInvocationFence previousInvocations?: AgentChildWorkInvocationHistory[]