Files
orca/src/shared/codex-subagent-roster.ts
T
Brennan Benson b4d732685c feat(agent-status): combine Codex child work through the shared main-agent status fold (#22475)
* feat(agent-status): combine Codex child work through the shared main-agent status fold

* docs(agent-status): correct two comments the waiting child-work arm made stale

A child failure reported in place as `blocked` now pins the row `waiting`, not
`working`; and no relay ever sent an unfolded `working` beside a waiting child.

* fix(agent-status): only a waiting child asks for a human

A child's `blocked` state means its task failed (the only producer maps a
failed background task to it, and the background-task view labels it
"failed"), not that a human must act. Folding it into the waiting arm would
surface a failed child as needs-you. It stays live work, as before this
series.

* docs(agent-status): say a waiting child, not a blocked one, makes the row wait

A child's blocked state means it failed; only its waiting state feeds the
waiting arm. Two fold comments, a test describe and two parity story names
still called the waiting child blocked.

* docs(agent-status): name where a child's wait is still lost, and pin the structured lane's real input

The doc said the Claude hook lane's rows match Codex and that every lane feeds a
child's wait into the fold. Neither holds: Claude keeps the wait in one slot the
next main agent event overwrites, the structured lane turns a child's prompt
into the main agent's own attention, and Codex drops its roster on a root Stop
when it tracks no child transcripts. The parity story now drives the structured
lane with the input it actually receives.
2026-09-23 22:09:07 -07:00

142 lines
4.0 KiB
TypeScript

import {
AGENT_MODEL_MAX_LENGTH,
AGENT_STATUS_MAX_SUBAGENTS,
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_TYPE_MAX_LENGTH,
type AgentSubagentSnapshot
} from './agent-status-types'
import { normalizeOptionalField } from './agent-status-field-normalization'
import {
agentChildWorkLiveness,
type AgentChildWorkLiveness
} from './agent-status-child-work-liveness'
const CODEX_SUBAGENT_ID_MAX_LENGTH = 64
export type CodexSubagentRoster = Map<string, TrackedCodexSubagent>
type TrackedCodexSubagent = {
agentType?: string
description?: string
model?: string
state: 'working' | 'waiting'
startedAt: number
}
export function upsertCodexSubagent(
roster: CodexSubagentRoster,
id: string,
fields: {
agentType?: string
description?: string
model?: string
state: 'working' | 'waiting'
},
now: number
): void {
const normalizedId = id.trim()
if (normalizedId.length === 0 || normalizedId.length > CODEX_SUBAGENT_ID_MAX_LENGTH) {
return
}
const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH)
const description = normalizeOptionalField(fields.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)
const model = normalizeOptionalField(fields.model, AGENT_MODEL_MAX_LENGTH)
const existing = roster.get(normalizedId)
if (existing) {
existing.agentType = agentType ?? existing.agentType
existing.description = description ?? existing.description
existing.model = model ?? existing.model
existing.state = fields.state
return
}
if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) {
return
}
roster.set(normalizedId, {
agentType,
description,
model,
state: fields.state,
startedAt: now
})
}
export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): void {
roster.delete(id.trim())
}
/**
* Record the model a already-tracked child is running. Deliberately narrower
* than `upsertCodexSubagent`: it never creates a roster entry and never touches
* `state`, so late model discovery from a child rollout cannot resurrect a
* finished child nor move any child's lifecycle.
*/
export function setCodexSubagentModel(
roster: CodexSubagentRoster,
id: string,
model: string | undefined
): void {
const normalizedModel = normalizeOptionalField(model, AGENT_MODEL_MAX_LENGTH)
if (!normalizedModel) {
return
}
const existing = roster.get(id.trim())
if (!existing) {
return
}
existing.model = normalizedModel
}
export function seedCodexSubagentRoster(
roster: CodexSubagentRoster,
snapshots: readonly AgentSubagentSnapshot[]
): void {
for (const snapshot of snapshots) {
if (snapshot.state !== 'working' && snapshot.state !== 'waiting') {
continue
}
upsertCodexSubagent(
roster,
snapshot.id,
{
agentType: snapshot.agentType,
description: snapshot.description,
model: snapshot.model,
state: snapshot.state
},
snapshot.startedAt
)
}
}
export function codexRosterToSnapshots(
roster: CodexSubagentRoster | undefined
): AgentSubagentSnapshot[] | undefined {
if (!roster || roster.size === 0) {
return undefined
}
const snapshots = Array.from(roster, ([id, tracked]) => ({
id,
agentType: tracked.agentType,
description: tracked.description,
model: tracked.model,
state: tracked.state,
startedAt: tracked.startedAt
}))
snapshots.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id))
return snapshots
}
/** The roster as child-work evidence for the shared fold. Every tracked child is a spawned
* agent thread, classified by the one kind test the other lanes use, so a waiting child
* reads `waiting` and a live one `working`; nothing here is a watch loop. */
export function codexRosterChildWorkLiveness(
roster: CodexSubagentRoster | undefined
): AgentChildWorkLiveness {
return agentChildWorkLiveness(
roster
? Array.from(roster.values(), (tracked) => ({ kind: 'agent' as const, state: tracked.state }))
: undefined
)
}