From f20ccc8a8bd5cd47e8b5ca70416a27665744d2f1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:58:13 -0700 Subject: [PATCH 01/14] test(agent-status): pin the legacy child-work projection of published background tasks --- ...nt-status-child-work-legacy-golden.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/shared/agent-status-child-work-legacy-golden.test.ts 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}]}"` + ) + }) +}) From b03e3a0d3b6e2f54696d3129463fa50f649048f5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:22:06 -0700 Subject: [PATCH 02/14] feat(agent-status): child work records say what the child is doing, how it ended, and when A child-work record gains the facts every surface needs from one host-owned record: the child that owns it (parentChildWorkId), whether the provider said it may outlive its launch turn (residency, host-only), what it is doing now (operation, with an open/reported basis), the newest thing it said (lastMessage), and when its current invocation settled (settledAt, stamped by admission, never by a producer). The codec enforces one membership x state legality matrix: live work is never done and carries no outcome or settle time; only a shell or monitor stores monitoring; settled work is done with an outcome and a settle time inside its own evidence window; an operation exists only while live and working, waiting or blocked. A settled record written without an outcome reads as unknown, never success. Malformed descriptive fields drop and keep the record. A new read-only view (AgentChildWorkView) is the one projection surfaces read; the legacy subagent and background-task shapes are derived from it with today's output unchanged for today's inputs. deriveAgentChildDisplayState folds a child's own state and the liveness of the work it owns through the same fold a parent row uses, so a child whose own work is idle or done reads monitoring while a shell it launched runs. Codex children get a thread_id alias kind. --- .../agent-status-store-relay-context.test.ts | 4 + .../agent-status-child-work-activity-codec.ts | 87 ++++ ...atus-child-work-admission-activity.test.ts | 229 ++++++++++ .../agent-status-child-work-admission-core.ts | 93 ++++- ...-status-child-work-admission-operations.ts | 15 +- .../agent-status-child-work-admission.ts | 8 + src/shared/agent-status-child-work-alias.ts | 11 +- src/shared/agent-status-child-work-codec.ts | 86 ++-- .../agent-status-child-work-legality.test.ts | 217 ++++++++++ .../agent-status-child-work-legality.ts | 56 +++ ...agent-status-child-work-projection.test.ts | 48 +-- .../agent-status-child-work-projection.ts | 120 +++--- src/shared/agent-status-child-work-resume.ts | 17 +- .../agent-status-child-work-value-guards.ts | 43 ++ .../agent-status-child-work-view.test.ts | 393 ++++++++++++++++++ src/shared/agent-status-child-work-view.ts | 226 ++++++++++ src/shared/agent-status-child-work.test.ts | 8 +- src/shared/agent-status-child-work.ts | 29 ++ 18 files changed, 1535 insertions(+), 155 deletions(-) create mode 100644 src/shared/agent-status-child-work-activity-codec.ts create mode 100644 src/shared/agent-status-child-work-admission-activity.test.ts create mode 100644 src/shared/agent-status-child-work-legality.test.ts create mode 100644 src/shared/agent-status-child-work-legality.ts create mode 100644 src/shared/agent-status-child-work-value-guards.ts create mode 100644 src/shared/agent-status-child-work-view.test.ts create mode 100644 src/shared/agent-status-child-work-view.ts 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..4422128a522 --- /dev/null +++ b/src/shared/agent-status-child-work-activity-codec.ts @@ -0,0 +1,87 @@ +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, + 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' +> + +function isResidency(value: unknown): value is AgentChildWorkResidency { + return typeof value === 'string' && RESIDENCY_SET.has(value) +} + +function isOperationBasis(value: unknown): value is AgentChildWorkOperationBasis { + return typeof value === 'string' && OPERATION_BASIS_SET.has(value) +} + +function parseOperation( + value: unknown, + clock: AgentChildWorkActivityClock +): AgentChildWorkOperation | undefined { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ['toolName', 'basis', 'observedAt'], ['input']) || + !isBoundedString(value.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) || + (value.input !== undefined && + !isBoundedString(value.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)) || + !isOperationBasis(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 undefined + } + return { + toolName: value.toolName, + ...(isBoundedString(value.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + ? { input: value.input } + : {}), + basis: value.basis, + observedAt: value.observedAt + } +} + +/** Descriptive fields drop when malformed and keep the record, which stays true without them. + * A dropped owner (including a self-reference) reads as the session's main agent. */ +export function parseAgentChildWorkActivityFields( + value: Record, + clock: AgentChildWorkActivityClock +): AgentChildWorkActivityFields { + const operation = parseOperation(value.operation, clock) + return { + ...(isBoundedString(value.parentChildWorkId) && value.parentChildWorkId !== clock.childWorkId + ? { parentChildWorkId: value.parentChildWorkId } + : {}), + ...(isResidency(value.residency) ? { residency: value.residency } : {}), + ...(operation ? { operation } : {}), + ...(isBoundedString(value.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) + ? { 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..69fba23c618 --- /dev/null +++ b/src/shared/agent-status-child-work-admission-activity.test.ts @@ -0,0 +1,229 @@ +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('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('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..f7303d51a96 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -4,14 +4,22 @@ import { type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' import { + 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 { agentChildWorkAllowsOperation } from './agent-status-child-work-legality' +import { normalizeOptionalField } from './agent-status-field-normalization' +import { + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_STATUS_TOOL_NAME_MAX_LENGTH +} from './agent-status-types' import type { AgentChildWorkAdmissionResult, AgentChildWorkAdoptRequest, @@ -73,18 +81,61 @@ 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 +} + +function admittedOperation( + request: AgentChildWorkObservationFields +): AgentChildWorkOperation | undefined { + const operation = request.operation + if (!operation || !agentChildWorkAllowsOperation(request.membership, request.state)) { + return undefined + } + const toolName = normalizeOptionalField(operation.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) + const input = normalizeOptionalField(operation.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + return toolName + ? { + toolName, + ...(input ? { input } : {}), + basis: operation.basis, + observedAt: operation.observedAt + } + : undefined +} + export function buildAgentChildWork( request: AgentChildWorkObservationFields & { parent: AgentStatusSubject provider: string }, - childWorkId: string, - firstObservedAt: number, - invocation: AgentChildWorkInvocationFence, - previousInvocations?: AgentChildWorkInput['previousInvocations'] + host: AgentChildWorkHostFields ): AgentChildWorkInput | null { + const operation = admittedOperation(request) + const lastMessage = normalizeOptionalField( + request.lastMessage, + AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH + ) return parseAgentChildWorkInput({ - childWorkId, + childWorkId: host.childWorkId, parent: request.parent, provider: request.provider, kind: request.kind, @@ -97,11 +148,20 @@ export function buildAgentChildWork( ...(request.model !== undefined ? { model: request.model } : {}), ...(request.totalTokens !== undefined ? { totalTokens: request.totalTokens } : {}), ...(request.providerTiming !== undefined ? { providerTiming: request.providerTiming } : {}), - firstObservedAt, + ...(request.parentChildWorkId !== undefined + ? { parentChildWorkId: request.parentChildWorkId } + : {}), + ...(request.residency !== undefined ? { residency: request.residency } : {}), + ...(operation ? { operation } : {}), + ...(lastMessage ? { lastMessage } : {}), + 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 }) } @@ -134,17 +194,18 @@ export function updateExistingAgentChildWork( child.membership === 'settled' && (request.membership !== 'settled' || request.state !== child.state || - request.outcome !== child.outcome) + // An omitted outcome was stored as `unknown`, so repeating the omission is the same ending. + (request.outcome ?? 'unknown') !== child.outcome) ) { return rejectAgentChildWorkAdmission('stale-invocation') } - const updated = buildAgentChildWork( - request, - child.childWorkId, - child.firstObservedAt, - child.invocation, - child.previousInvocations - ) + const updated = buildAgentChildWork(request, { + childWorkId: child.childWorkId, + firstObservedAt: child.firstObservedAt, + invocation: child.invocation, + previousInvocations: child.previousInvocations, + settledAt: agentChildWorkSettledAt(request, child) + }) return updated ? commitAgentChildWork(store, updated, aliases, false, removeAliases) : rejectAgentChildWorkAdmission('invalid') 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.ts b/src/shared/agent-status-child-work-admission.ts index 493fba612e2..8949ed3f57a 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 { @@ -37,6 +39,12 @@ export type AgentChildWorkObservationFields = { model?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming + parentChildWorkId?: AgentChildWorkId + residency?: AgentChildWorkResidency + /** Raw provider text is fine: admission folds it to the one-line previews a status row uses, + * and drops it when the state cannot carry an operation (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..8b4b2aa6e67 100644 --- a/src/shared/agent-status-child-work-alias.ts +++ b/src/shared/agent-status-child-work-alias.ts @@ -15,7 +15,10 @@ 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). */ +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 +88,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 +108,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.ts b/src/shared/agent-status-child-work-codec.ts index 616a7430022..6de245e50e8 100644 --- a/src/shared/agent-status-child-work-codec.ts +++ b/src/shared/agent-status-child-work-codec.ts @@ -17,8 +17,15 @@ import { type AgentChildWorkState } from './agent-status-child-work' import { parseAgentStatusSubject } from './agent-status-subject' +import { + hasOnlyKeys, + isBoundedString, + 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) @@ -26,44 +33,6 @@ const CHILD_WORK_STATE_SET: ReadonlySet = new Set(AGENT_CHILD_WORK_STATE 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 } @@ -186,6 +155,11 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | 'model', 'totalTokens', 'providerTiming', + 'parentChildWorkId', + 'residency', + 'operation', + 'lastMessage', + 'settledAt', 'previousInvocations' ] ) || @@ -195,7 +169,7 @@ 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 || @@ -228,6 +202,34 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | if (Object.values(labels).includes(null)) { return 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 + const activity = parseAgentChildWorkActivityFields(value, { + childWorkId: value.childWorkId, + firstObservedAt: value.firstObservedAt, + observedAt: value.observedAt + }) + 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( (entry) => `${entry.fence.invocationId}\0${entry.fence.generation}` ) @@ -246,15 +248,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-legality.test.ts b/src/shared/agent-status-child-work-legality.test.ts new file mode 100644 index 00000000000..cfe5a8212ac --- /dev/null +++ b/src/shared/agent-status-child-work-legality.test.ts @@ -0,0 +1,217 @@ +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.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' }], + ['an empty owner id', { parentChildWorkId: '' }], + ['a child that owns itself', { parentChildWorkId: 'child-1' }] + ])('drops %s and keeps the record', (_case, field) => { + expect(parseAgentChildWorkInput({ ...live(), ...field })).toEqual(live()) + }) + + it.each([ + ['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' } + ] + ])('still 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..f832c714fea 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,16 +64,16 @@ 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 - ) + const resumed = buildAgentChildWork(request, { + childWorkId: child.childWorkId, + firstObservedAt: child.firstObservedAt, + invocation: nextFence, + previousInvocations, + settledAt: agentChildWorkSettledAt(request) + }) const retainedFences = [nextFence, ...previousInvocations.map((entry) => entry.fence)] const removeAliases = agentChildWorkAliasesForChild(store, child.childWorkId) .filter( 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..bbdfd9dd5f2 --- /dev/null +++ b/src/shared/agent-status-child-work-value-guards.ts @@ -0,0 +1,43 @@ +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)) + ) +} + +/** Nonempty, trimmed, single-line text 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) { + const code = value.charCodeAt(index) + if (code <= 0x1f || code === 0x7f) { + 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..655269b113c --- /dev/null +++ b/src/shared/agent-status-child-work-view.test.ts @@ -0,0 +1,393 @@ +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 + expect( + foldAgentLeadStatus({ + leadState: 'done', + interrupted: false, + childWorkLiveness: agentChildWorkLiveness(ownedShell) + }) + ).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..7a96e4a4401 --- /dev/null +++ b/src/shared/agent-status-child-work-view.ts @@ -0,0 +1,226 @@ +import { foldAgentLeadStatus } from './agent-lead-status-fold' +import type { + AgentChildWorkAliasInput, + 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. */ +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. +const PROVIDER_ID_ALIAS_ORDER: readonly AgentChildWorkAliasKind[] = [ + 'task_id', + 'thread_id', + 'tool_use_id' +] + +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 +} + +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 = new Map() + for (const alias of aliases) { + aliasesByChild.set(alias.childWorkId, [...(aliasesByChild.get(alias.childWorkId) ?? []), alias]) + } + 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 = new Map() + for (const view of views) { + if (view.parentChildWorkId !== undefined) { + owned.set(view.parentChildWorkId, [...(owned.get(view.parentChildWorkId) ?? []), view]) + } + } + 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..069f11bc639 100644 --- a/src/shared/agent-status-child-work.ts +++ b/src/shared/agent-status-child-work.ts @@ -19,12 +19,29 @@ 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 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 +78,20 @@ export type AgentChildWorkInput = { model?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming + /** The child that owns this work (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[] From d4c46328124f021ce738026b78c0ec7a27224e3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:49:31 -0700 Subject: [PATCH 03/14] fix(agent-status): an unknown child ending can gain its real outcome; operation clock clamped A settled child whose ending was first recorded as unknown (a roster omission can land a tick before the frame naming the outcome) now accepts the definite outcome for the same invocation and keeps its original settle time. A definite ending still never changes, and a later unknown ending is ignored rather than downgrading it. Admission clamps operation.observedAt into the child's evidence window, so an operation stamped in provider time is kept instead of silently dropped. The record codec is pinned as host-internal: it rejects a whole record over one unknown key, so a ratchet test fails if anything outside the host store and admission path imports it. --- ...atus-child-work-admission-activity.test.ts | 86 +++++++++++++++++++ .../agent-status-child-work-admission-core.ts | 42 ++++++--- .../agent-status-child-work-admission.ts | 3 +- ...t-status-child-work-codec-boundary.test.ts | 29 +++++++ src/shared/agent-status-child-work-codec.ts | 4 + src/shared/agent-status-child-work-view.ts | 3 +- 6 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 src/shared/agent-status-child-work-codec-boundary.test.ts diff --git a/src/shared/agent-status-child-work-admission-activity.test.ts b/src/shared/agent-status-child-work-admission-activity.test.ts index 69fba23c618..8796f57aa31 100644 --- a/src/shared/agent-status-child-work-admission-activity.test.ts +++ b/src/shared/agent-status-child-work-admission-activity.test.ts @@ -70,6 +70,25 @@ describe('child-work admission of what a child is doing', () => { expect(child?.lastMessage).toBe('Line one Line two') }) + 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()) @@ -189,6 +208,73 @@ describe('child-work settlement stamping', () => { }) }) + 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)('ignores %s after a definite ending', (_case, ending) => { + const { store, admission } = setup() + admission.announce( + observation({ + state: 'done', + membership: 'settled', + outcome: 'cancelled', + observedAt: 20, + lastMessage: 'Stopped by user' + }) + ) + const before = store.getChild('child-1') + expect( + admission.announce( + observation({ state: 'done', membership: 'settled', observedAt: 25, ...ending }) + ) + ).toEqual({ + accepted: true, + childWorkId: 'child-1', + revision: before?.revision, + created: false + }) + expect(store.getChild('child-1')).toEqual(before) + }) + it('stamps a child first seen already settled at that observation', () => { const { store, admission } = setup() admission.announce( diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index f7303d51a96..e048aac7252 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -104,7 +104,8 @@ export function agentChildWorkSettledAt( } function admittedOperation( - request: AgentChildWorkObservationFields + request: AgentChildWorkObservationFields, + firstObservedAt: number ): AgentChildWorkOperation | undefined { const operation = request.operation if (!operation || !agentChildWorkAllowsOperation(request.membership, request.state)) { @@ -117,7 +118,8 @@ function admittedOperation( toolName, ...(input ? { input } : {}), basis: operation.basis, - observedAt: operation.observedAt + // 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 } @@ -129,7 +131,7 @@ export function buildAgentChildWork( }, host: AgentChildWorkHostFields ): AgentChildWorkInput | null { - const operation = admittedOperation(request) + const operation = admittedOperation(request, host.firstObservedAt) const lastMessage = normalizeOptionalField( request.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH @@ -183,6 +185,23 @@ 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); a definite ending never changes, + * and a later `unknown` claims nothing about it. An omitted outcome is stored as `unknown`. */ +function settledEvidence( + child: AgentChildWorkRecord, + request: AgentChildWorkAnnounceRequest | AgentChildWorkAdoptRequest +): 'admit' | 'ignore' | 'conflict' { + if (request.membership !== 'settled' || request.state !== child.state) { + return 'conflict' + } + const requested = request.outcome ?? 'unknown' + if (requested === child.outcome || child.outcome === 'unknown') { + return 'admit' + } + return requested === 'unknown' ? 'ignore' : 'conflict' +} + export function updateExistingAgentChildWork( store: AgentStatusStore, request: AgentChildWorkAnnounceRequest | AgentChildWorkAdoptRequest, @@ -190,15 +209,18 @@ export function updateExistingAgentChildWork( aliases: AgentChildWorkAliasInput[], removeAliases: string[] = [] ): AgentChildWorkAdmissionResult { - if ( - child.membership === 'settled' && - (request.membership !== 'settled' || - request.state !== child.state || - // An omitted outcome was stored as `unknown`, so repeating the omission is the same ending. - (request.outcome ?? 'unknown') !== child.outcome) - ) { + const evidence = child.membership === 'settled' ? settledEvidence(child, request) : 'admit' + if (evidence === 'conflict') { return rejectAgentChildWorkAdmission('stale-invocation') } + if (evidence === 'ignore') { + return { + accepted: true, + childWorkId: child.childWorkId, + revision: child.revision, + created: false + } + } const updated = buildAgentChildWork(request, { childWorkId: child.childWorkId, firstObservedAt: child.firstObservedAt, diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index 8949ed3f57a..81fa8e2db98 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -42,7 +42,8 @@ export type AgentChildWorkObservationFields = { parentChildWorkId?: AgentChildWorkId residency?: AgentChildWorkResidency /** Raw provider text is fine: admission folds it to the one-line previews a status row uses, - * and drops it when the state cannot carry an operation (settled, idle, unverifiable). */ + * clamps `observedAt` into [firstObservedAt, request observedAt], and drops the operation when + * the state cannot carry one (settled, idle, unverifiable). Labels are NOT normalized. */ operation?: AgentChildWorkOperation lastMessage?: string observedAt: number 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 6de245e50e8..6bbf5a65cd8 100644 --- a/src/shared/agent-status-child-work-codec.ts +++ b/src/shared/agent-status-child-work-codec.ts @@ -129,6 +129,10 @@ function parseOptionalLabel(value: unknown, maxLength = MAX_LABEL_LENGTH): strin return value === undefined ? '' : isBoundedString(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). */ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | null { if ( !isRecord(value) || diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index 7a96e4a4401..28a81de1314 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -22,7 +22,8 @@ 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. */ + * 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 From 0d396ad86d812a3d4c1024121e47c0f3832e046c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:10:48 -0700 Subject: [PATCH 04/14] test(agent-status): pass the fold-parity input as a value; name the hook lane's alias kinds --- src/shared/agent-status-child-work-alias.ts | 6 +++++- src/shared/agent-status-child-work-view.test.ts | 17 ++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/shared/agent-status-child-work-alias.ts b/src/shared/agent-status-child-work-alias.ts index 8b4b2aa6e67..539c9c38214 100644 --- a/src/shared/agent-status-child-work-alias.ts +++ b/src/shared/agent-status-child-work-alias.ts @@ -15,7 +15,11 @@ import { const CHILD_ALIAS_KEY_PREFIX = 'agent-child-work-alias-v1:' const MAX_ALIAS_PART_LENGTH = 512 -/** `thread_id` names a child by its own provider thread (a Codex subagent). */ +/** + * `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) diff --git a/src/shared/agent-status-child-work-view.test.ts b/src/shared/agent-status-child-work-view.test.ts index 655269b113c..9c79a545183 100644 --- a/src/shared/agent-status-child-work-view.test.ts +++ b/src/shared/agent-status-child-work-view.test.ts @@ -200,13 +200,16 @@ describe('deriveAgentChildDisplayState', () => { (_l, view) => { // The same owned work, folded for a CLI agent whose own turn is over. const ownedShell = [{ kind: 'command', state: 'working' }] as const - expect( - foldAgentLeadStatus({ - leadState: 'done', - interrupted: false, - childWorkLiveness: agentChildWorkLiveness(ownedShell) - }) - ).toEqual({ stateName: 'working', workingMode: 'monitoring' }) + // 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' ) From 0d9c7431931b9fef102821d0f487ecb113bead28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:49:31 -0700 Subject: [PATCH 05/14] fix(agent-status): a sparse child observation never erases what the record already knows Admission merged a later observation by replacing the whole record, so an ending that knew only that the child was gone dropped its name, model and token count, and an outcome refinement dropped the recorded last message. Labels now fill or replace but never clear, tokens never shrink, and a settled ending keeps its last message unless new evidence carries one. The provider-id preference is keyed by alias kind so a new kind cannot compile without a rank. --- .../agent-status-child-work-admission-core.ts | 74 ++++++--- ...status-child-work-admission-sparse.test.ts | 152 ++++++++++++++++++ .../agent-status-child-work-admission.ts | 2 + src/shared/agent-status-child-work-resume.ts | 18 ++- src/shared/agent-status-child-work-view.ts | 23 +-- 5 files changed, 235 insertions(+), 34 deletions(-) create mode 100644 src/shared/agent-status-child-work-admission-sparse.test.ts diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index e048aac7252..779677e74d1 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -124,18 +124,57 @@ function admittedOperation( : undefined } +/** Descriptive facts a sparse observation leaves unsaid (a roster omission knows only "it is + * gone"): a later write fills or replaces them, never erases them. Tokens are cumulative and never + * shrink; a settled ending keeps its last message unless new evidence carries one. `operation` is + * not among them: its absence means the child stopped doing it. */ +function retainedDescription( + request: AgentChildWorkObservationFields, + invocation: AgentChildWorkInvocationFence, + prior: AgentChildWorkRecord | undefined +): Pick< + AgentChildWorkInput, + 'name' | 'description' | 'agentType' | 'model' | 'totalTokens' | 'lastMessage' +> { + const sameEnding = + prior?.membership === 'settled' && agentChildWorkFencesEqual(prior.invocation, invocation) + const lastMessage = + normalizeOptionalField(request.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) ?? + (sameEnding ? prior.lastMessage : undefined) + // An out-of-range count passes through unmerged so the codec still refuses it. + const requestedTokens = request.totalTokens + const totalTokens = + requestedTokens !== undefined && + Number.isSafeInteger(requestedTokens) && + requestedTokens >= 0 && + prior?.totalTokens !== undefined + ? Math.max(requestedTokens, prior.totalTokens) + : (requestedTokens ?? prior?.totalTokens) + const name = request.name ?? prior?.name + const description = request.description ?? prior?.description + const agentType = request.agentType ?? prior?.agentType + const model = request.model ?? prior?.model + return { + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(agentType !== undefined ? { agentType } : {}), + ...(model !== undefined ? { model } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(lastMessage ? { lastMessage } : {}) + } +} + +/** Builds the record admission writes. `prior` is the stored record when this observation + * updates or resumes an existing child; its descriptive facts survive a sparser request. */ export function buildAgentChildWork( request: AgentChildWorkObservationFields & { parent: AgentStatusSubject provider: string }, - host: AgentChildWorkHostFields + host: AgentChildWorkHostFields, + prior?: AgentChildWorkRecord ): AgentChildWorkInput | null { const operation = admittedOperation(request, host.firstObservedAt) - const lastMessage = normalizeOptionalField( - request.lastMessage, - AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH - ) return parseAgentChildWorkInput({ childWorkId: host.childWorkId, parent: request.parent, @@ -144,18 +183,13 @@ export function buildAgentChildWork( 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 } : {}), + ...retainedDescription(request, host.invocation, prior), ...(request.providerTiming !== undefined ? { providerTiming: request.providerTiming } : {}), ...(request.parentChildWorkId !== undefined ? { parentChildWorkId: request.parentChildWorkId } : {}), ...(request.residency !== undefined ? { residency: request.residency } : {}), ...(operation ? { operation } : {}), - ...(lastMessage ? { lastMessage } : {}), firstObservedAt: host.firstObservedAt, observedAt: request.observedAt, ...(host.settledAt !== undefined ? { settledAt: host.settledAt } : {}), @@ -221,13 +255,17 @@ export function updateExistingAgentChildWork( created: false } } - const updated = buildAgentChildWork(request, { - childWorkId: child.childWorkId, - firstObservedAt: child.firstObservedAt, - invocation: child.invocation, - previousInvocations: child.previousInvocations, - settledAt: agentChildWorkSettledAt(request, child) - }) + const updated = buildAgentChildWork( + request, + { + 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) : rejectAgentChildWorkAdmission('invalid') 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..b10dd5d57e5 --- /dev/null +++ b/src/shared/agent-status-child-work-admission-sparse.test.ts @@ -0,0 +1,152 @@ +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('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 still refuses an invalid token count', () => { + 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 }))).toEqual({ + accepted: false, + reason: 'invalid' + }) + }) + + it('carries labels and tokens into a resumed invocation but not the old ending message', () => { + const { store, admission } = setup() + admission.announce( + observation({ + ...described, + state: 'done', + membership: 'settled', + outcome: 'succeeded', + observedAt: 20, + lastMessage: 'First run done' + }) + ) + 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).toMatchObject({ membership: 'live', ...described }) + expect(child).not.toHaveProperty('lastMessage') + }) +}) diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index 81fa8e2db98..f108b66a696 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -28,6 +28,8 @@ export type AgentChildWorkObservationAlias = { alias: string } +/** An observation may be sparse. Omitted labels, tokens and a settled child's last message keep + * their stored values; tokens never shrink. Omitting `operation` clears it. */ export type AgentChildWorkObservationFields = { kind: AgentChildWorkKind state: AgentChildWorkState diff --git a/src/shared/agent-status-child-work-resume.ts b/src/shared/agent-status-child-work-resume.ts index f832c714fea..4b3cbf8401e 100644 --- a/src/shared/agent-status-child-work-resume.ts +++ b/src/shared/agent-status-child-work-resume.ts @@ -67,13 +67,17 @@ export function resumeAgentChildWork( ...(child.settledAt !== undefined ? { settledAt: child.settledAt } : {}) } ].slice(-AGENT_CHILD_WORK_INVOCATION_HISTORY_MAX) - const resumed = buildAgentChildWork(request, { - childWorkId: child.childWorkId, - firstObservedAt: child.firstObservedAt, - invocation: nextFence, - previousInvocations, - settledAt: agentChildWorkSettledAt(request) - }) + const resumed = buildAgentChildWork( + request, + { + 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) .filter( diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index 28a81de1314..f48a579f5dd 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -1,7 +1,8 @@ import { foldAgentLeadStatus } from './agent-lead-status-fold' -import type { - AgentChildWorkAliasInput, - AgentChildWorkAliasKind +import { + AGENT_CHILD_WORK_ALIAS_KINDS, + type AgentChildWorkAliasInput, + type AgentChildWorkAliasKind } from './agent-status-child-work-alias' import { agentChildWorkFencesEqual, @@ -54,12 +55,16 @@ export type AgentChildWorkViewAlias = Pick< 'childWorkId' | 'aliasKind' | 'alias' | 'fence' > -// Stable handles before per-call ones: a spawn's tool id changes on every resume. -const PROVIDER_ID_ALIAS_ORDER: readonly AgentChildWorkAliasKind[] = [ - 'task_id', - 'thread_id', - 'tool_use_id' -] +// 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, From 41d814ba3c3c1fef870d4c366050e17eeb2796cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:56 -0700 Subject: [PATCH 06/14] fix(agent-status): a child's owner, residency and last message outlive a sparse observation A settle that knows only that the child is gone dropped who owned it and whether it ran in the background, and the last thing the child said while live. They now survive like the labels do: the last message for its invocation, owner and residency for the child. --- .../agent-status-child-work-admission-core.ts | 39 +++++++++++-------- ...status-child-work-admission-sparse.test.ts | 32 +++++++++++++++ .../agent-status-child-work-admission.ts | 5 ++- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index 779677e74d1..15874fb95f6 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -124,23 +124,30 @@ function admittedOperation( : undefined } -/** Descriptive facts a sparse observation leaves unsaid (a roster omission knows only "it is - * gone"): a later write fills or replaces them, never erases them. Tokens are cumulative and never - * shrink; a settled ending keeps its last message unless new evidence carries one. `operation` is - * not among them: its absence means the child stopped doing it. */ -function retainedDescription( +/** Facts a sparse observation leaves unsaid (a roster omission knows only "it is gone"): a later + * write fills or replaces them, never erases them. Tokens are cumulative and never shrink; the + * last message lasts for its invocation. `operation` is not among them: its absence means the + * child stopped doing it. */ +function retainedFacts( request: AgentChildWorkObservationFields, invocation: AgentChildWorkInvocationFence, prior: AgentChildWorkRecord | undefined ): Pick< AgentChildWorkInput, - 'name' | 'description' | 'agentType' | 'model' | 'totalTokens' | 'lastMessage' + | 'name' + | 'description' + | 'agentType' + | 'model' + | 'totalTokens' + | 'lastMessage' + | 'parentChildWorkId' + | 'residency' > { - const sameEnding = - prior?.membership === 'settled' && agentChildWorkFencesEqual(prior.invocation, invocation) + const sameInvocation = + prior !== undefined && agentChildWorkFencesEqual(prior.invocation, invocation) const lastMessage = normalizeOptionalField(request.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) ?? - (sameEnding ? prior.lastMessage : undefined) + (sameInvocation ? prior.lastMessage : undefined) // An out-of-range count passes through unmerged so the codec still refuses it. const requestedTokens = request.totalTokens const totalTokens = @@ -154,18 +161,22 @@ function retainedDescription( const description = request.description ?? prior?.description const agentType = request.agentType ?? prior?.agentType const model = request.model ?? prior?.model + const parentChildWorkId = request.parentChildWorkId ?? prior?.parentChildWorkId + const residency = request.residency ?? prior?.residency return { ...(name !== undefined ? { name } : {}), ...(description !== undefined ? { description } : {}), ...(agentType !== undefined ? { agentType } : {}), ...(model !== undefined ? { model } : {}), ...(totalTokens !== undefined ? { totalTokens } : {}), - ...(lastMessage ? { lastMessage } : {}) + ...(lastMessage ? { lastMessage } : {}), + ...(parentChildWorkId !== undefined ? { parentChildWorkId } : {}), + ...(residency !== undefined ? { residency } : {}) } } /** Builds the record admission writes. `prior` is the stored record when this observation - * updates or resumes an existing child; its descriptive facts survive a sparser request. */ + * updates or resumes an existing child; what it knew survives a sparser request. */ export function buildAgentChildWork( request: AgentChildWorkObservationFields & { parent: AgentStatusSubject @@ -183,12 +194,8 @@ export function buildAgentChildWork( state: request.state, membership: request.membership, ...(request.outcome !== undefined ? { outcome: request.outcome } : {}), - ...retainedDescription(request, host.invocation, prior), + ...retainedFacts(request, host.invocation, prior), ...(request.providerTiming !== undefined ? { providerTiming: request.providerTiming } : {}), - ...(request.parentChildWorkId !== undefined - ? { parentChildWorkId: request.parentChildWorkId } - : {}), - ...(request.residency !== undefined ? { residency: request.residency } : {}), ...(operation ? { operation } : {}), firstObservedAt: host.firstObservedAt, observedAt: request.observedAt, diff --git a/src/shared/agent-status-child-work-admission-sparse.test.ts b/src/shared/agent-status-child-work-admission-sparse.test.ts index b10dd5d57e5..a13c02a0541 100644 --- a/src/shared/agent-status-child-work-admission-sparse.test.ts +++ b/src/shared/agent-status-child-work-admission-sparse.test.ts @@ -90,6 +90,38 @@ describe('child-work admission of sparse observations', () => { }) }) + 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 })) diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index f108b66a696..f010596f7b0 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -28,8 +28,9 @@ export type AgentChildWorkObservationAlias = { alias: string } -/** An observation may be sparse. Omitted labels, tokens and a settled child's last message keep - * their stored values; tokens never shrink. Omitting `operation` clears it. */ +/** An observation may be sparse: an omitted label, token count, owner, residency, or (within one + * invocation) last message keeps its stored value, and tokens never shrink. Omitting `operation` + * clears it. */ export type AgentChildWorkObservationFields = { kind: AgentChildWorkKind state: AgentChildWorkState From af93ae58987756b3a571b49102429fdae475dc0a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:27:39 -0700 Subject: [PATCH 07/14] fix(agent-status): an unknown ending keeps a definite outcome and still lands its evidence A settled child's later `unknown` (or omitted) ending was acknowledged without a write, so a late last message, token count, alias or reclassification it carried was dropped while the caller was told it was accepted. The outcome now merges like every other sparse fact: an `unknown` claims nothing and keeps the stored definite outcome, and only a different definite ending conflicts. --- ...atus-child-work-admission-activity.test.ts | 28 +++++++++---- .../agent-status-child-work-admission-core.ts | 42 ++++++++----------- .../agent-status-child-work-admission.ts | 4 +- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/shared/agent-status-child-work-admission-activity.test.ts b/src/shared/agent-status-child-work-admission-activity.test.ts index 8796f57aa31..e7164553560 100644 --- a/src/shared/agent-status-child-work-admission-activity.test.ts +++ b/src/shared/agent-status-child-work-admission-activity.test.ts @@ -250,7 +250,7 @@ describe('child-work settlement stamping', () => { it.each([ ['an explicit unknown', { outcome: 'unknown' }], ['an omitted outcome', {}] - ] as const)('ignores %s after a definite ending', (_case, ending) => { + ] as const)('keeps a definite ending through %s and admits the rest of it', (_case, ending) => { const { store, admission } = setup() admission.announce( observation({ @@ -261,18 +261,28 @@ describe('child-work settlement stamping', () => { lastMessage: 'Stopped by user' }) ) - const before = store.getChild('child-1') expect( admission.announce( - observation({ state: 'done', membership: 'settled', observedAt: 25, ...ending }) + 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 + }) ) - ).toEqual({ - accepted: true, - childWorkId: 'child-1', - revision: before?.revision, - created: false + ).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.getChild('child-1')).toEqual(before) + expect(store.getAliasesForChild('child-1').map((alias) => alias.alias)).toContain('toolu-1') }) it('stamps a child first seen already settled at that observation', () => { diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index 15874fb95f6..eb4e1344eb1 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -126,14 +126,15 @@ function admittedOperation( /** Facts a sparse observation leaves unsaid (a roster omission knows only "it is gone"): a later * write fills or replaces them, never erases them. Tokens are cumulative and never shrink; the - * last message lasts for its invocation. `operation` is not among them: its absence means the - * child stopped doing it. */ + * last message and a definite outcome last for their invocation (an `unknown` ending claims + * nothing). `operation` is not among them: its absence means the child stopped doing it. */ function retainedFacts( request: AgentChildWorkObservationFields, invocation: AgentChildWorkInvocationFence, prior: AgentChildWorkRecord | undefined ): Pick< AgentChildWorkInput, + | 'outcome' | 'name' | 'description' | 'agentType' @@ -148,6 +149,10 @@ function retainedFacts( const lastMessage = normalizeOptionalField(request.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) ?? (sameInvocation ? prior.lastMessage : undefined) + const outcome = + request.outcome === undefined || request.outcome === 'unknown' + ? ((sameInvocation ? prior.outcome : undefined) ?? request.outcome) + : request.outcome // An out-of-range count passes through unmerged so the codec still refuses it. const requestedTokens = request.totalTokens const totalTokens = @@ -164,6 +169,7 @@ function retainedFacts( const parentChildWorkId = request.parentChildWorkId ?? prior?.parentChildWorkId const residency = request.residency ?? prior?.residency return { + ...(outcome !== undefined ? { outcome } : {}), ...(name !== undefined ? { name } : {}), ...(description !== undefined ? { description } : {}), ...(agentType !== undefined ? { agentType } : {}), @@ -193,7 +199,6 @@ export function buildAgentChildWork( kind: request.kind, state: request.state, membership: request.membership, - ...(request.outcome !== undefined ? { outcome: request.outcome } : {}), ...retainedFacts(request, host.invocation, prior), ...(request.providerTiming !== undefined ? { providerTiming: request.providerTiming } : {}), ...(operation ? { operation } : {}), @@ -227,20 +232,18 @@ export function commitAgentChildWork( } /** 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); a definite ending never changes, - * and a later `unknown` claims nothing about it. An omitted outcome is stored as `unknown`. */ -function settledEvidence( + * 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 -): 'admit' | 'ignore' | 'conflict' { - if (request.membership !== 'settled' || request.state !== child.state) { - return 'conflict' - } +): boolean { const requested = request.outcome ?? 'unknown' - if (requested === child.outcome || child.outcome === 'unknown') { - return 'admit' - } - return requested === 'unknown' ? 'ignore' : 'conflict' + return ( + request.membership !== 'settled' || + request.state !== child.state || + (requested !== 'unknown' && child.outcome !== 'unknown' && requested !== child.outcome) + ) } export function updateExistingAgentChildWork( @@ -250,18 +253,9 @@ export function updateExistingAgentChildWork( aliases: AgentChildWorkAliasInput[], removeAliases: string[] = [] ): AgentChildWorkAdmissionResult { - const evidence = child.membership === 'settled' ? settledEvidence(child, request) : 'admit' - if (evidence === 'conflict') { + if (child.membership === 'settled' && conflictsWithSettled(child, request)) { return rejectAgentChildWorkAdmission('stale-invocation') } - if (evidence === 'ignore') { - return { - accepted: true, - childWorkId: child.childWorkId, - revision: child.revision, - created: false - } - } const updated = buildAgentChildWork( request, { diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index f010596f7b0..1ab0c985f8a 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -29,8 +29,8 @@ export type AgentChildWorkObservationAlias = { } /** An observation may be sparse: an omitted label, token count, owner, residency, or (within one - * invocation) last message keeps its stored value, and tokens never shrink. Omitting `operation` - * clears it. */ + * invocation) last message or definite outcome keeps its stored value, and tokens never shrink. + * Omitting `operation` clears it. */ export type AgentChildWorkObservationFields = { kind: AgentChildWorkKind state: AgentChildWorkState From 3936581d9dbc1358fbe56b6b774b67ab6a693715 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:27:47 -0700 Subject: [PATCH 08/14] fix(agent-status): group child aliases and owned work in one pass Appending by spread copied each bucket on every insert, quadratic in a bucket's size on the projection and per-row liveness paths. --- src/shared/agent-status-child-work-view.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index f48a579f5dd..89d8ff92648 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -118,10 +118,7 @@ export function projectAgentChildWorkViews( aliases: readonly AgentChildWorkViewAlias[] ): AgentChildWorkView[] { const byId = new Map(records.map((record) => [record.childWorkId, record])) - const aliasesByChild = new Map() - for (const alias of aliases) { - aliasesByChild.set(alias.childWorkId, [...(aliasesByChild.get(alias.childWorkId) ?? []), alias]) - } + const aliasesByChild = Map.groupBy(aliases, (alias) => alias.childWorkId) return records.map((record) => { const providerId = providerIdFor(record, aliasesByChild.get(record.childWorkId) ?? []) const owner = resolvedOwner(record, byId) @@ -159,12 +156,7 @@ export function agentChildWorkOwnedLiveness( views: readonly AgentChildWorkOwnershipView[], ownerId: AgentChildWorkId ): AgentChildWorkLiveness { - const owned = new Map() - for (const view of views) { - if (view.parentChildWorkId !== undefined) { - owned.set(view.parentChildWorkId, [...(owned.get(view.parentChildWorkId) ?? []), view]) - } - } + const owned = Map.groupBy(views, (view) => view.parentChildWorkId) const seen = new Set([ownerId]) const frontier = [ownerId] const liveDescendants: AgentChildWorkOwnershipView[] = [] From e17dd35eab0beba78d5ff226ba350e9c4c1fb16f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:39:49 -0700 Subject: [PATCH 09/14] fix(agent-status): group child aliases and owned work without Map.groupBy The relay runs this core on Node 18, which lacks Map.groupBy; a plain loop into a Map is equally linear and portable. --- src/shared/agent-status-child-work-view.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index 89d8ff92648..80324d854e4 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -82,6 +82,21 @@ function providerIdFor( 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 @@ -118,7 +133,7 @@ export function projectAgentChildWorkViews( aliases: readonly AgentChildWorkViewAlias[] ): AgentChildWorkView[] { const byId = new Map(records.map((record) => [record.childWorkId, record])) - const aliasesByChild = Map.groupBy(aliases, (alias) => alias.childWorkId) + const aliasesByChild = groupedBy(aliases, (alias) => alias.childWorkId) return records.map((record) => { const providerId = providerIdFor(record, aliasesByChild.get(record.childWorkId) ?? []) const owner = resolvedOwner(record, byId) @@ -156,7 +171,7 @@ export function agentChildWorkOwnedLiveness( views: readonly AgentChildWorkOwnershipView[], ownerId: AgentChildWorkId ): AgentChildWorkLiveness { - const owned = Map.groupBy(views, (view) => view.parentChildWorkId) + const owned = groupedBy(views, (view) => view.parentChildWorkId) const seen = new Set([ownerId]) const frontier = [ownerId] const liveDescendants: AgentChildWorkOwnershipView[] = [] From 6ea0e40210092eea1981007c204ae21f0d36f672 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:39:54 -0700 Subject: [PATCH 10/14] fix(agent-status): a child's activity and last message survive the codec Admission folded raw provider text with the status-row normalizer, which can leave a tab or other control character and can end a truncation on a space. The record codec drops such a field, so a long command cut at a space, a tab in a command, or an escape in a message silently erased the child's current operation or last message. Admission now folds control characters to spaces and trims the cut, with the codec's own control-character predicate. --- ...atus-child-work-admission-activity.test.ts | 27 +++++++++++++++++++ .../agent-status-child-work-admission-core.ts | 21 ++++++++++++--- .../agent-status-child-work-value-guards.ts | 8 ++++-- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/shared/agent-status-child-work-admission-activity.test.ts b/src/shared/agent-status-child-work-admission-activity.test.ts index e7164553560..66328bbc422 100644 --- a/src/shared/agent-status-child-work-admission-activity.test.ts +++ b/src/shared/agent-status-child-work-admission-activity.test.ts @@ -70,6 +70,33 @@ describe('child-work admission of what a child is doing', () => { 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( diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index eb4e1344eb1..09cdb7e8d78 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -14,6 +14,7 @@ import { type AgentChildWorkRecord } from './agent-status-child-work' import { parseAgentChildWorkInput } from './agent-status-child-work-codec' +import { isControlCharCode } from './agent-status-child-work-value-guards' import { agentChildWorkAllowsOperation } from './agent-status-child-work-legality' import { normalizeOptionalField } from './agent-status-field-normalization' import { @@ -103,6 +104,20 @@ export function agentChildWorkSettledAt( : request.observedAt } +/** The status-row preview can keep a control character or end its cut on a space, and the codec + * drops such a field outright; this folds both so raw provider text always lands. */ +function childWorkPreview(value: string | undefined, maxLength: number): string | undefined { + const preview = normalizeOptionalField(value, maxLength) + if (preview === undefined) { + return undefined + } + let printable = '' + for (let index = 0; index < preview.length; index += 1) { + printable += isControlCharCode(preview.charCodeAt(index)) ? ' ' : preview[index] + } + return printable.trim() || undefined +} + function admittedOperation( request: AgentChildWorkObservationFields, firstObservedAt: number @@ -111,8 +126,8 @@ function admittedOperation( if (!operation || !agentChildWorkAllowsOperation(request.membership, request.state)) { return undefined } - const toolName = normalizeOptionalField(operation.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) - const input = normalizeOptionalField(operation.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + const toolName = childWorkPreview(operation.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) + const input = childWorkPreview(operation.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) return toolName ? { toolName, @@ -147,7 +162,7 @@ function retainedFacts( const sameInvocation = prior !== undefined && agentChildWorkFencesEqual(prior.invocation, invocation) const lastMessage = - normalizeOptionalField(request.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) ?? + childWorkPreview(request.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) ?? (sameInvocation ? prior.lastMessage : undefined) const outcome = request.outcome === undefined || request.outcome === 'unknown' diff --git a/src/shared/agent-status-child-work-value-guards.ts b/src/shared/agent-status-child-work-value-guards.ts index bbdfd9dd5f2..40d5bbc29d1 100644 --- a/src/shared/agent-status-child-work-value-guards.ts +++ b/src/shared/agent-status-child-work-value-guards.ts @@ -16,6 +16,11 @@ export function hasOnlyKeys( ) } +/** A C0 control or DEL: never part of a record's one-line text. */ +export function isControlCharCode(code: number): boolean { + return code <= 0x1f || code === 0x7f +} + /** Nonempty, trimmed, single-line text within `maxLength`. */ export function isBoundedString( value: unknown, @@ -30,8 +35,7 @@ export function isBoundedString( return false } for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code <= 0x1f || code === 0x7f) { + if (isControlCharCode(value.charCodeAt(index))) { return false } } From 9efde6b0f39b4c83b48569970df56dbe11544e49 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:05:06 -0700 Subject: [PATCH 11/14] refactor(agent-status): parse child facts, merge, then check the record Admission merged provider values before anything knew they were valid, and the codec then either rejected the whole record or silently dropped the field depending on how old the field was. A malformed owner erased the stored one, a label cut on a space rejected the announce, and a bad token count blocked a settle. Admission now parses every descriptive fact into a value the codec accepts or "not said", merges it over the stored record with one rule per fact (a typed map, so a new request field without a rule fails to compile), and the codec checks the result. Text goes through one normalizer and the codec accepts exactly its image; any value outside it is a writer bug and rejects. The owner is now a fact of the invocation, like the last message. --- .../agent-status-child-work-activity-codec.ts | 52 +++-- .../agent-status-child-work-admission-core.ts | 177 +++++++------- ...-status-child-work-admission-parse.test.ts | 216 ++++++++++++++++++ ...status-child-work-admission-sparse.test.ts | 30 ++- .../agent-status-child-work-admission.ts | 12 +- src/shared/agent-status-child-work-codec.ts | 55 +++-- .../agent-status-child-work-legality.test.ts | 28 ++- .../agent-status-child-work-value-guards.ts | 34 ++- src/shared/agent-status-child-work.ts | 4 +- 9 files changed, 467 insertions(+), 141 deletions(-) create mode 100644 src/shared/agent-status-child-work-admission-parse.test.ts diff --git a/src/shared/agent-status-child-work-activity-codec.ts b/src/shared/agent-status-child-work-activity-codec.ts index 4422128a522..83e07087f69 100644 --- a/src/shared/agent-status-child-work-activity-codec.ts +++ b/src/shared/agent-status-child-work-activity-codec.ts @@ -14,6 +14,7 @@ import { import { hasOnlyKeys, isBoundedString, + isChildWorkText, isRecord, isTimestamp } from './agent-status-child-work-value-guards' @@ -31,57 +32,70 @@ type AgentChildWorkActivityClock = Pick< 'childWorkId' | 'firstObservedAt' | 'observedAt' > -function isResidency(value: unknown): value is AgentChildWorkResidency { +export function isAgentChildWorkResidency(value: unknown): value is AgentChildWorkResidency { return typeof value === 'string' && RESIDENCY_SET.has(value) } -function isOperationBasis(value: unknown): value is AgentChildWorkOperationBasis { +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 | undefined { +): AgentChildWorkOperation | null { if ( !isRecord(value) || !hasOnlyKeys(value, ['toolName', 'basis', 'observedAt'], ['input']) || - !isBoundedString(value.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) || + !isChildWorkText(value.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) || (value.input !== undefined && - !isBoundedString(value.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)) || - !isOperationBasis(value.basis) || + !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 undefined + return null } return { toolName: value.toolName, - ...(isBoundedString(value.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) - ? { input: value.input } - : {}), + ...(typeof value.input === 'string' ? { input: value.input } : {}), basis: value.basis, observedAt: value.observedAt } } -/** Descriptive fields drop when malformed and keep the record, which stays true without them. - * A dropped owner (including a self-reference) reads as the session's main agent. */ +/** Null when any present field is outside what admission can produce. */ export function parseAgentChildWorkActivityFields( value: Record, clock: AgentChildWorkActivityClock -): AgentChildWorkActivityFields { - const operation = parseOperation(value.operation, clock) +): 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 { - ...(isBoundedString(value.parentChildWorkId) && value.parentChildWorkId !== clock.childWorkId + ...(typeof value.parentChildWorkId === 'string' ? { parentChildWorkId: value.parentChildWorkId } : {}), - ...(isResidency(value.residency) ? { residency: value.residency } : {}), + ...(isAgentChildWorkResidency(value.residency) ? { residency: value.residency } : {}), ...(operation ? { operation } : {}), - ...(isBoundedString(value.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) - ? { lastMessage: value.lastMessage } - : {}) + ...(typeof value.lastMessage === 'string' ? { lastMessage: value.lastMessage } : {}) } } diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index 09cdb7e8d78..06e40b7c231 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -4,6 +4,8 @@ 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, @@ -13,10 +15,20 @@ import { type AgentChildWorkOperation, type AgentChildWorkRecord } from './agent-status-child-work' -import { parseAgentChildWorkInput } from './agent-status-child-work-codec' -import { isControlCharCode } from './agent-status-child-work-value-guards' +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 { normalizeOptionalField } from './agent-status-field-normalization' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH @@ -104,30 +116,33 @@ export function agentChildWorkSettledAt( : request.observedAt } -/** The status-row preview can keep a control character or end its cut on a space, and the codec - * drops such a field outright; this folds both so raw provider text always lands. */ -function childWorkPreview(value: string | undefined, maxLength: number): string | undefined { - const preview = normalizeOptionalField(value, maxLength) - if (preview === undefined) { - return undefined - } - let printable = '' - for (let index = 0; index < preview.length; index += 1) { - printable += isControlCharCode(preview.charCodeAt(index)) ? ' ' : preview[index] - } - return printable.trim() || undefined +/** 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 admittedOperation( +function parseObservedOperation( request: AgentChildWorkObservationFields, firstObservedAt: number ): AgentChildWorkOperation | undefined { const operation = request.operation - if (!operation || !agentChildWorkAllowsOperation(request.membership, request.state)) { + if ( + !operation || + !agentChildWorkAllowsOperation(request.membership, request.state) || + !isAgentChildWorkOperationBasis(operation.basis) || + !Number.isFinite(operation.observedAt) + ) { return undefined } - const toolName = childWorkPreview(operation.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) - const input = childWorkPreview(operation.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + 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, @@ -139,65 +154,74 @@ function admittedOperation( : undefined } -/** Facts a sparse observation leaves unsaid (a roster omission knows only "it is gone"): a later - * write fills or replaces them, never erases them. Tokens are cumulative and never shrink; the - * last message and a definite outcome last for their invocation (an `unknown` ending claims - * nothing). `operation` is not among them: its absence means the child stopped doing it. */ -function retainedFacts( +/** 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, - invocation: AgentChildWorkInvocationFence, - prior: AgentChildWorkRecord | undefined -): Pick< - AgentChildWorkInput, - | 'outcome' - | 'name' - | 'description' - | 'agentType' - | 'model' - | 'totalTokens' - | 'lastMessage' - | 'parentChildWorkId' - | 'residency' -> { - const sameInvocation = - prior !== undefined && agentChildWorkFencesEqual(prior.invocation, invocation) - const lastMessage = - childWorkPreview(request.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH) ?? - (sameInvocation ? prior.lastMessage : undefined) - const outcome = - request.outcome === undefined || request.outcome === 'unknown' - ? ((sameInvocation ? prior.outcome : undefined) ?? request.outcome) - : request.outcome - // An out-of-range count passes through unmerged so the codec still refuses it. - const requestedTokens = request.totalTokens - const totalTokens = - requestedTokens !== undefined && - Number.isSafeInteger(requestedTokens) && - requestedTokens >= 0 && - prior?.totalTokens !== undefined - ? Math.max(requestedTokens, prior.totalTokens) - : (requestedTokens ?? prior?.totalTokens) - const name = request.name ?? prior?.name - const description = request.description ?? prior?.description - const agentType = request.agentType ?? prior?.agentType - const model = request.model ?? prior?.model - const parentChildWorkId = request.parentChildWorkId ?? prior?.parentChildWorkId - const residency = request.residency ?? prior?.residency + 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 { - ...(outcome !== undefined ? { outcome } : {}), - ...(name !== undefined ? { name } : {}), - ...(description !== undefined ? { description } : {}), - ...(agentType !== undefined ? { agentType } : {}), - ...(model !== undefined ? { model } : {}), - ...(totalTokens !== undefined ? { totalTokens } : {}), - ...(lastMessage ? { lastMessage } : {}), - ...(parentChildWorkId !== undefined ? { parentChildWorkId } : {}), - ...(residency !== undefined ? { residency } : {}) + // 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, + providerTiming: said.providerTiming ?? prior?.providerTiming, + // 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, + // Its absence means the child stopped doing it. + operation: said.operation } } -/** Builds the record admission writes. `prior` is the stored record when this observation - * updates or resumes an existing child; what it knew survives a sparser request. */ +/** 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 @@ -206,7 +230,8 @@ export function buildAgentChildWork( host: AgentChildWorkHostFields, prior?: AgentChildWorkRecord ): AgentChildWorkInput | null { - const operation = admittedOperation(request, host.firstObservedAt) + const run = + prior && agentChildWorkFencesEqual(prior.invocation, host.invocation) ? prior : undefined return parseAgentChildWorkInput({ childWorkId: host.childWorkId, parent: request.parent, @@ -214,9 +239,7 @@ export function buildAgentChildWork( kind: request.kind, state: request.state, membership: request.membership, - ...retainedFacts(request, host.invocation, prior), - ...(request.providerTiming !== undefined ? { providerTiming: request.providerTiming } : {}), - ...(operation ? { operation } : {}), + ...mergeObservationFacts(parseObservationFacts(request, host), prior, run), firstObservedAt: host.firstObservedAt, observedAt: request.observedAt, ...(host.settledAt !== undefined ? { settledAt: host.settledAt } : {}), 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..6d69eaefe77 --- /dev/null +++ b/src/shared/agent-status-child-work-admission-parse.test.ts @@ -0,0 +1,216 @@ +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, unknown][] = [ + ['name', ' \t '], + ['description', '\r\n'], + ['agentType', '\u2028'], + ['model', ''], + ['totalTokens', -1], + ['totalTokens', 1.5], + ['totalTokens', Number.NaN], + ['providerTiming', { startedAt: -1 }], + ['providerTiming', { startedAt: 3, extra: 1 }], + ['parentChildWorkId', ''], + ['parentChildWorkId', 'child-1'], + ['parentChildWorkId', 'x'.repeat(257)], + ['residency', 'detached'], + ['lastMessage', '\u0085 \u2029'] +] + +describe('a malformed fact never erases what the record knows', () => { + it.each(ERASURE)('keeps %s through a request carrying %j', (field, 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('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 index a13c02a0541..a18110a6c35 100644 --- a/src/shared/agent-status-child-work-admission-sparse.test.ts +++ b/src/shared/agent-status-child-work-admission-sparse.test.ts @@ -146,18 +146,18 @@ describe('child-work admission of sparse observations', () => { expect(store.getChild('child-1')).toEqual(before) }) - it('replaces a label the request carries and still refuses an invalid token count', () => { + 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 }))).toEqual({ - accepted: false, - reason: 'invalid' + expect(admission.announce(observation({ totalTokens: -1, observedAt: 12 }))).toMatchObject({ + accepted: true }) + expect(store.getChild('child-1')?.totalTokens).toBe(5_000) }) - it('carries labels and tokens into a resumed invocation but not the old ending message', () => { + function settledFirstRun() { const { store, admission } = setup() admission.announce( observation({ @@ -166,19 +166,33 @@ describe('child-work admission of sparse observations', () => { membership: 'settled', outcome: 'succeeded', observedAt: 20, + parentChildWorkId: 'child-spawner', lastMessage: 'First run done' }) ) - expect( + const resume = (overrides: Partial = {}) => admission.resume({ - ...observation({ observedAt: 30 }), + ...observation({ observedAt: 30, ...overrides }), childWorkId: 'child-1', expectedFence: { invocationId: 'invocation-1', generation: 1 }, nextFence: { invocationId: 'invocation-2', generation: 2 } }) - ).toMatchObject({ accepted: true }) + return { store, resume } + } + + it('carries labels and tokens into a resumed invocation but not its ending or spawner', () => { + const { store, resume } = settledFirstRun() + expect(resume()).toMatchObject({ accepted: true }) const child = store.getChild('child-1') expect(child).toMatchObject({ membership: 'live', ...described }) expect(child).not.toHaveProperty('lastMessage') + // 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 1ab0c985f8a..0818240302f 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -28,9 +28,10 @@ export type AgentChildWorkObservationAlias = { alias: string } -/** An observation may be sparse: an omitted label, token count, owner, residency, or (within one - * invocation) last message or definite outcome keeps its stored value, and tokens never shrink. - * Omitting `operation` clears it. */ +/** An observation may be sparse, and raw provider text is fine: admission folds text to one line. + * An omitted or malformed label, token count, residency or timing keeps its stored value; the + * owner, last message and a definite outcome last only for their invocation. Tokens never + * shrink. Omitting `operation` clears it. */ export type AgentChildWorkObservationFields = { kind: AgentChildWorkKind state: AgentChildWorkState @@ -44,9 +45,8 @@ export type AgentChildWorkObservationFields = { providerTiming?: AgentChildWorkProviderTiming parentChildWorkId?: AgentChildWorkId residency?: AgentChildWorkResidency - /** Raw provider text is fine: admission folds it to the one-line previews a status row uses, - * clamps `observedAt` into [firstObservedAt, request observedAt], and drops the operation when - * the state cannot carry one (settled, idle, unverifiable). Labels are NOT normalized. */ + /** 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 diff --git a/src/shared/agent-status-child-work-codec.ts b/src/shared/agent-status-child-work-codec.ts index 6bbf5a65cd8..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, @@ -20,14 +22,14 @@ 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_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) @@ -67,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 } @@ -125,14 +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). */ + * (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) || @@ -178,10 +187,7 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | !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 } @@ -189,21 +195,33 @@ 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) { - return null - } - if (Object.values(labels).includes(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 } // A settled record written without these fields (an older writer) reads as an unknown ending @@ -215,11 +233,6 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | : settled ? value.observedAt : undefined - const activity = parseAgentChildWorkActivityFields(value, { - childWorkId: value.childWorkId, - firstObservedAt: value.firstObservedAt, - observedAt: value.observedAt - }) if ( !isAgentChildWorkLifecycleLegal({ kind: value.kind, diff --git a/src/shared/agent-status-child-work-legality.test.ts b/src/shared/agent-status-child-work-legality.test.ts index cfe5a8212ac..e555fbf3225 100644 --- a/src/shared/agent-status-child-work-legality.test.ts +++ b/src/shared/agent-status-child-work-legality.test.ts @@ -117,6 +117,18 @@ describe('child-work descriptive fields', () => { 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: {} } }], @@ -139,13 +151,15 @@ describe('child-work descriptive fields', () => { ['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' }] - ])('drops %s and keeps the record', (_case, field) => { - expect(parseAgentChildWorkInput({ ...live(), ...field })).toEqual(live()) - }) - - it.each([ + ['a child that owns itself', { parentChildWorkId: 'child-1' }], ['an unknown top-level key', { note: 'x' }], [ 'a settle time that is not a timestamp', @@ -155,7 +169,7 @@ describe('child-work descriptive fields', () => { 'an outcome outside the vocabulary', { membership: 'settled', state: 'done', outcome: 'crashed' } ] - ])('still rejects %s', (_case, field) => { + ])('rejects %s', (_case, field) => { expect(parseAgentChildWorkInput({ ...live(), ...field })).toBeNull() }) }) diff --git a/src/shared/agent-status-child-work-value-guards.ts b/src/shared/agent-status-child-work-value-guards.ts index 40d5bbc29d1..1206981dbfd 100644 --- a/src/shared/agent-status-child-work-value-guards.ts +++ b/src/shared/agent-status-child-work-value-guards.ts @@ -1,3 +1,5 @@ +import { normalizeOptionalField } from './agent-status-field-normalization' + export const AGENT_CHILD_WORK_ID_MAX_LENGTH = 256 export function isRecord(value: unknown): value is Record { @@ -17,11 +19,39 @@ export function hasOnlyKeys( } /** A C0 control or DEL: never part of a record's one-line text. */ -export function isControlCharCode(code: number): boolean { +function isControlCharCode(code: number): boolean { return code <= 0x1f || code === 0x7f } -/** Nonempty, trimmed, single-line text within `maxLength`. */ +/** 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 diff --git a/src/shared/agent-status-child-work.ts b/src/shared/agent-status-child-work.ts index 069f11bc639..c537bae4acc 100644 --- a/src/shared/agent-status-child-work.ts +++ b/src/shared/agent-status-child-work.ts @@ -22,6 +22,8 @@ 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] @@ -78,7 +80,7 @@ export type AgentChildWorkInput = { model?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming - /** The child that owns this work (a nested agent's spawner, or the agent that launched a + /** 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. */ From d6276870d9a457a792429624826268546ef3158d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:07:03 -0700 Subject: [PATCH 12/14] refactor(agent-status): name each erasure row by what makes its value malformed --- ...-status-child-work-admission-parse.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/shared/agent-status-child-work-admission-parse.test.ts b/src/shared/agent-status-child-work-admission-parse.test.ts index 6d69eaefe77..270316c6a07 100644 --- a/src/shared/agent-status-child-work-admission-parse.test.ts +++ b/src/shared/agent-status-child-work-admission-parse.test.ts @@ -152,25 +152,25 @@ const GOOD = { } 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, unknown][] = [ - ['name', ' \t '], - ['description', '\r\n'], - ['agentType', '\u2028'], - ['model', ''], - ['totalTokens', -1], - ['totalTokens', 1.5], - ['totalTokens', Number.NaN], - ['providerTiming', { startedAt: -1 }], - ['providerTiming', { startedAt: 3, extra: 1 }], - ['parentChildWorkId', ''], - ['parentChildWorkId', 'child-1'], - ['parentChildWorkId', 'x'.repeat(257)], - ['residency', 'detached'], - ['lastMessage', '\u0085 \u2029'] +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], + ['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 %j', (field, bad) => { + 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({ From 7bf43e20d3c1e1a02f6464274625ef0f0ca1f377 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:07:44 -0700 Subject: [PATCH 13/14] refactor(agent-status): pin the token parse where the max-merge cannot hide it --- ...agent-status-child-work-admission-parse.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shared/agent-status-child-work-admission-parse.test.ts b/src/shared/agent-status-child-work-admission-parse.test.ts index 270316c6a07..d4988e22d39 100644 --- a/src/shared/agent-status-child-work-admission-parse.test.ts +++ b/src/shared/agent-status-child-work-admission-parse.test.ts @@ -160,6 +160,7 @@ const ERASURE: [keyof typeof GOOD, string, unknown][] = [ ['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', ''], @@ -180,6 +181,19 @@ describe('a malformed fact never erases what the record knows', () => { 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( From 467e1f53d8307fcd87817f2ac5e56c7809ca0ee4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:21:01 -0700 Subject: [PATCH 14/14] fix(agent-status): provider timing lasts only for its own run providerTiming records the provider's start and end of one run. Keeping it across a resume left a live restarted child claiming the previous run's completion time. It now follows the owner and last message: kept within an invocation, reset by a new one. --- src/shared/agent-status-child-work-admission-core.ts | 3 ++- .../agent-status-child-work-admission-sparse.test.ts | 8 ++++++-- src/shared/agent-status-child-work-admission.ts | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index 06e40b7c231..31c9db7cd42 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -206,7 +206,6 @@ function mergeObservationFacts( agentType: said.agentType ?? prior?.agentType, model: said.model ?? prior?.model, residency: said.residency ?? prior?.residency, - providerTiming: said.providerTiming ?? prior?.providerTiming, // Cumulative, so a late or duplicate frame never shrinks it. totalTokens: said.totalTokens !== undefined && prior?.totalTokens !== undefined @@ -215,6 +214,8 @@ function mergeObservationFacts( // 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 } diff --git a/src/shared/agent-status-child-work-admission-sparse.test.ts b/src/shared/agent-status-child-work-admission-sparse.test.ts index a18110a6c35..d1a678e8f46 100644 --- a/src/shared/agent-status-child-work-admission-sparse.test.ts +++ b/src/shared/agent-status-child-work-admission-sparse.test.ts @@ -167,7 +167,8 @@ describe('child-work admission of sparse observations', () => { outcome: 'succeeded', observedAt: 20, parentChildWorkId: 'child-spawner', - lastMessage: 'First run done' + lastMessage: 'First run done', + providerTiming: { startedAt: 12, completedAt: 20 } }) ) const resume = (overrides: Partial = {}) => @@ -180,12 +181,15 @@ describe('child-work admission of sparse observations', () => { return { store, resume } } - it('carries labels and tokens into a resumed invocation but not its ending or spawner', () => { + 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') }) diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index 0818240302f..cf839485be8 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -29,9 +29,9 @@ export type AgentChildWorkObservationAlias = { } /** An observation may be sparse, and raw provider text is fine: admission folds text to one line. - * An omitted or malformed label, token count, residency or timing keeps its stored value; the - * owner, last message and a definite outcome last only for their invocation. Tokens never - * shrink. Omitting `operation` clears it. */ + * 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