Files
orca/src/shared/agent-status-child-work-activity-codec.ts
T
Brennan Benson 58ba75b5a5 feat(agent-status): child work records say what the child is doing, how it ended, and when (#22521)
* test(agent-status): pin the legacy child-work projection of published background tasks

* 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.

* 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.

* test(agent-status): pass the fold-parity input as a value; name the hook lane's alias kinds

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* refactor(agent-status): name each erasure row by what makes its value malformed

* refactor(agent-status): pin the token parse where the max-merge cannot hide it

* 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.
2026-09-24 21:44:35 -07:00

102 lines
3.4 KiB
TypeScript

import {
AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH,
AGENT_CHILD_WORK_OPERATION_BASES,
AGENT_CHILD_WORK_RESIDENCIES,
type AgentChildWorkInput,
type AgentChildWorkOperation,
type AgentChildWorkOperationBasis,
type AgentChildWorkResidency
} from './agent-status-child-work'
import {
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_STATUS_TOOL_NAME_MAX_LENGTH
} from './agent-status-types'
import {
hasOnlyKeys,
isBoundedString,
isChildWorkText,
isRecord,
isTimestamp
} from './agent-status-child-work-value-guards'
const RESIDENCY_SET: ReadonlySet<string> = new Set(AGENT_CHILD_WORK_RESIDENCIES)
const OPERATION_BASIS_SET: ReadonlySet<string> = new Set(AGENT_CHILD_WORK_OPERATION_BASES)
export type AgentChildWorkActivityFields = Pick<
AgentChildWorkInput,
'parentChildWorkId' | 'residency' | 'operation' | 'lastMessage'
>
type AgentChildWorkActivityClock = Pick<
AgentChildWorkInput,
'childWorkId' | 'firstObservedAt' | 'observedAt'
>
export function isAgentChildWorkResidency(value: unknown): value is AgentChildWorkResidency {
return typeof value === 'string' && RESIDENCY_SET.has(value)
}
export function isAgentChildWorkOperationBasis(
value: unknown
): value is AgentChildWorkOperationBasis {
return typeof value === 'string' && OPERATION_BASIS_SET.has(value)
}
/** An owner is another child's id; a child never owns itself. */
export function isAgentChildWorkOwner(value: unknown, childWorkId: string): value is string {
return isBoundedString(value) && value !== childWorkId
}
function parseOperation(
value: unknown,
clock: AgentChildWorkActivityClock
): AgentChildWorkOperation | null {
if (
!isRecord(value) ||
!hasOnlyKeys(value, ['toolName', 'basis', 'observedAt'], ['input']) ||
!isChildWorkText(value.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) ||
(value.input !== undefined &&
!isChildWorkText(value.input, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)) ||
!isAgentChildWorkOperationBasis(value.basis) ||
!isTimestamp(value.observedAt) ||
// The record's own clock is the newest evidence for this child, so it bounds the operation's.
value.observedAt < clock.firstObservedAt ||
value.observedAt > clock.observedAt
) {
return null
}
return {
toolName: value.toolName,
...(typeof value.input === 'string' ? { input: value.input } : {}),
basis: value.basis,
observedAt: value.observedAt
}
}
/** Null when any present field is outside what admission can produce. */
export function parseAgentChildWorkActivityFields(
value: Record<string, unknown>,
clock: AgentChildWorkActivityClock
): AgentChildWorkActivityFields | null {
const operation =
value.operation === undefined ? undefined : parseOperation(value.operation, clock)
if (
operation === null ||
(value.parentChildWorkId !== undefined &&
!isAgentChildWorkOwner(value.parentChildWorkId, clock.childWorkId)) ||
(value.residency !== undefined && !isAgentChildWorkResidency(value.residency)) ||
(value.lastMessage !== undefined &&
!isChildWorkText(value.lastMessage, AGENT_CHILD_WORK_LAST_MESSAGE_MAX_LENGTH))
) {
return null
}
return {
...(typeof value.parentChildWorkId === 'string'
? { parentChildWorkId: value.parentChildWorkId }
: {}),
...(isAgentChildWorkResidency(value.residency) ? { residency: value.residency } : {}),
...(operation ? { operation } : {}),
...(typeof value.lastMessage === 'string' ? { lastMessage: value.lastMessage } : {})
}
}