Files
orca/src/main/codex/codex-subagent-activity.ts
T
Brennan Benson 8be17ddf3a feat(chat): host writes one saved row per subagent (#27947)
* feat(chat): read one saved row per subagent

Adds the singular `subagent` journal block (one row per child, revised in
place, with a plain-text twin for older clients) and teaches every reader
to draw it: desktop and phone fold a turn's sibling rows into the existing
"Ran N subagents" row, the client and off-page rosters name children from
their own rows, the reopen pass settles a row a dead host left working,
and the phone and worker-transcript wires bound it. No producer writes the
block yet; that switch lands in the follow-up.

* feat(chat): host writes one saved row per subagent

Claude, Codex and Grok now only report child-work evidence. The host writes each
subagent's row from its child record, so the chat row, strip and sidebar read one
lifecycle. Deletes the shared subagent tracker, the per-agent rosters and their
caps. An accepted Stop settles only the run it was sent to, provisionally, until
that run reports its own ending. Work the host store has no room to record keeps
the running-tasks strip and its Stop instead of reading as idle.

* fix(chat): open subagent background flag, separate nested group ids, page window skips subagent rows

- background is an open boolean so a later build's value never fails a chat load
- a helper's own helpers fold under a groupId of their own, so the phone opens them apart
- each subagent's own row rides along with the page window instead of counting as conversation

* fix(chat): subagent row writer reads the live turn once, stop acks survive a failed batch

- the row writer reads the journal's live turn at most once per change and only for a
  subagent with no saved, queued or in-flight row; a revision keeps its first scope
- a background stop names each accepted task as its answer lands, so a stop-all batch that
  times out or fails part way still ends the runs it was answered for (Claude and Grok)
- the strip's untargeted Stop rule lives in the shared view (offersStopAll); both strips
  inline it again; incomplete tracking no longer holds commands or rewind
- clients admit a frame whose only change is incomplete tracking
- drop the Claude stop's dead publish; the real-CLI stop test asserts the run's own ending
- list the writer test with the real SQLite tests

* fix(chat): a late Grok report never revives an ended helper; Codex keeps announced helpers

- Grok: once a helper's run ended, a later progress or repeated spawn sends no live evidence,
  so its record and saved row stay ended after the next send retires the record; the test rig
  now retires finished records on each turn open, as the host does
- Codex: past the bound, only a child that ran and ended is evicted, so an announced helper
  that has not started keeps its record, row, Stop and parent
- cut: Codex parentTurnId (written, never read), the Claude identities' turn-end forwarding
  (call the corrections directly), the duplicate Grok outcome map
- update stale comments that described the deleted producers

* test(chat): Claude frames to saved row, kept Codex path and usage rules, agent ingestion exemption

- a captured background Claude helper keeps its saved row working past its spawn result and
  the parent turn's end, through the real adapter, host and child records, until its own end
- restore focused tests for the Codex root path, label trim and placeholder, usage before
  announcement and parent usage
- the per-session live limit test uses tasks: subagents are exempt from it

* fix(chat): incomplete tracking follows the refused children, and the strip's Stop reaches them

- the host remembers which children the store refused for room, by handle; each leaves when a
  later report of it is admitted or it reports its own end, so incomplete clears on a truthful
  signal; the provider session's end still clears all, and past 512 a flag holds until then
- a parent row the store refused counts every live child it reports as refused
- with work no record lists, an untargeted strip Stop while no turn runs ends the agent through
  the idle sweep's stop; under a running turn it leaves the agent alone
- the background Stop moves into its own module

* fix(chat): the incomplete Stop is offered for every agent and decides inside its step

- the strip offers the untargeted Stop for work no row lists whenever no turn runs, whatever
  untargeted stop the provider has (ACP agents, Codex before it proves terminal stops), since the
  host reaches it by ending the agent; never under a running turn
- the Stop reads whether work is unlisted and whether a turn runs inside its own step, after the
  listed stops land, so a wake-up turn opened meanwhile is never ended
- Codex executions past the bound evict the oldest ended child, else the oldest never started
- test the writer's unnamed-subagent label; qualify the writer's retry comment

* refactor(chat): the subagent row writer joins the views with their records

Drops the shared host-only row projection and its hook-server read: the writer's wiring reads
the views every surface reads and the records they come from, and takes residency and the
ending's basis from the record.

* refactor(chat): Grok subagent evidence reads its ending through the one outcome map

* refactor(chat): a subagent's group entry as one object

* fix(chat): a background Stop with nothing listed asks the agent for no task stops

So an agent with no stop route (an ACP agent the probe found none for) never refuses before the
Stop ends the work no row lists. Tests: the unlisted case is untargeted and reads unlisted work
inside the step; the repeat-press rig lists the task its Stops reach; reflow the writer header.

* test(chat): a nested helper's saved parent; describe how incomplete tracking clears

* fix(chat): a closed session claims no Stop; a helper's row follows its own running work

- a session close Orca proves is loss of contact, not a Stop the CLI accepted: Claude no longer
  ends what still ran as stopped, so the host settles it unknown and its row reads unverifiable,
  as before this stack; stop-acknowledged comes only from a Stop the CLI accepted
- a helper that finished while work it started still runs is written working, by the strip's
  own rule; once that work ends, or the session ends, it reads its own ending again
- the CLI starting a completed helper again under the same spawn call is the helper waking when
  its work ends: the decoder reports it as a restart, so its one row revises in place and ends
  with its real duration; after any other ending such a start is still read as late
- tests from a captured 2.1.280 frame order; item 4 Stops pin their stop-acknowledged basis

* refactor(chat): drop the unused Stop basis on a child's own ending

Only the acknowledged-Stop edge writes that basis now; reflow the decoder header.

* fix(chat): an older history page keeps a turn's subagent ordinals and row

A page now reaches back to the first sibling of each subagent group it holds part of, at most one more page of conversation, so loading the older page no longer renumbers same-named helpers or moves (and closes) their folded row.
2026-10-12 22:53:42 -07:00

174 lines
7.3 KiB
TypeScript

// Reading Codex's subagent wire shapes.
//
// Established by a live probe against `codex app-server` 0.152.1, not inferred:
// * `subAgentActivity` items carry `{kind, agentThreadId, agentPath}`, and each
// one arrives TWICE — via `item/started` and again via `item/completed`.
// * `agentPath` is a tree path (`/root`, `/root/list_directory`); the trailing
// segment is a semantic task name and the only label available. There is no
// `thread/started` for a child, so nickname/role/depth do not exist.
// * Codex's DEFAULT multi-agent mode sends no `subAgentActivity` at all; a
// helper appears only in the `collabAgentToolCall` items that spawn, message,
// wait on or close it (read in `codex-collab-agent-tool-call.ts`). Either item
// announces the same child, keyed by its thread id, and child turn events own
// its execution state.
// * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the
// latest frame replaces the previous one — it is never accumulated.
import {
codexCollabHelperLabel,
readCodexCollabAgentToolCall
} from './codex-collab-agent-tool-call'
import type { CodexThreadItem } from './codex-thread-item-identity'
export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity'
export const CODEX_TOKEN_USAGE_METHOD = 'thread/tokenUsage/updated'
export type CodexSubagentActivity = {
kind: string
agentThreadId: string
agentPath: string | null
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentActivity | null {
if (item.type !== CODEX_SUBAGENT_ITEM_TYPE) {
return null
}
const agentThreadId = nonEmptyString(item.agentThreadId)
if (!agentThreadId) {
return null
}
return {
kind: nonEmptyString(item.kind) ?? '',
agentThreadId,
agentPath: nonEmptyString(item.agentPath)
}
}
/** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */
export function codexSubagentPathSegments(agentPath: string | null): string[] {
return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0)
}
/** The one path segment that names the parent turn itself rather than a child.
* Compared after the same normalization the label uses, not against the raw
* string: `/root/` and `/root//` are the same node as `/root`, and a check that
* disagreed with `codexSubagentPathSegments` would let one path be both the
* turn and a child of it — a phantom row labelled `root` inflating the group.
* Only this segment is the root; `/morpheus` is single-segment too but IS a
* child. */
const CODEX_ROOT_AGENT_SEGMENT = 'root'
/**
* Whether an activity item describes the ROOT of the agent tree rather than a
* spawned child. Counting the root would make the parent turn report itself as
* its own subagent.
*
* A path-less item cannot be placed in the tree at all, so it is treated as a
* child: dropping it would lose a real spawn, while an extra row is visible and
* self-correcting.
*/
export function isCodexRootAgentActivity(activity: CodexSubagentActivity): boolean {
const segments = codexSubagentPathSegments(activity.agentPath)
return segments.length === 1 && segments[0] === CODEX_ROOT_AGENT_SEGMENT
}
/** Row label: the agent path's trailing segment, trimmed. A segment with nothing
* visible in it survives the empty-segment filter but would draw a nameless row,
* so it reads as no label and the caller's placeholder takes over. Trimmed
* because the caller keys its collision ordinals on this string: ` read ` and
* `read` render identically and must therefore collide. */
export function codexSubagentLabel(activity: CodexSubagentActivity): string | null {
const trailing = codexSubagentPathSegments(activity.agentPath).at(-1)?.trim()
return trailing !== undefined && trailing.length > 0 ? trailing : null
}
/** A child the item says exists, from either wire shape Codex announces one with. */
export type CodexSubagentAnnouncement = {
agentThreadId: string
label: string | null
/** The item is the spawn itself, so the thread that carried it spawned the child. */
spawned: boolean
}
/** The children an item announces: a `subAgentActivity`'s, or every helper a collab call names,
* keyed by thread id so repeats announce one child. Neither the tree root nor the session's own
* thread is a child. Any call counts, since a helper whose spawn was never seen (compacted out of
* history) is known only from the calls on it. A spawn counts whatever its status: Codex reports
* `failed` for a helper that errored at birth, yet names the thread it created, and that thread
* can still run. */
export function readCodexSubagentAnnouncements(
item: CodexThreadItem,
primaryThreadId: string | null
): CodexSubagentAnnouncement[] {
return readAnnouncements(item).filter(({ agentThreadId }) => agentThreadId !== primaryThreadId)
}
function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] {
const activity = readCodexSubagentActivity(item)
if (activity) {
return isCodexRootAgentActivity(activity)
? []
: [
{
agentThreadId: activity.agentThreadId,
label: codexSubagentLabel(activity),
spawned: activity.kind === 'started'
}
]
}
const call = readCodexCollabAgentToolCall(item)
if (!call) {
return []
}
const spawned = call.tool === 'spawnAgent'
// Only a spawn's prompt describes its helper; another call's prompt is a later message to it,
// so a helper no spawn named reads as any unnamed subagent does.
const label = spawned ? codexCollabHelperLabel(call.prompt) : null
return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned }))
}
export type CodexThreadTokenTotal = { threadId: string; totalTokens: number }
/** `{threadId, tokenUsage: {total: {totalTokens}}}`. Older builds put the total
* on the envelope, so both shapes are accepted. */
export function readCodexThreadTokenTotal(params: unknown): CodexThreadTokenTotal | null {
const root = record(params)
if (!root) {
return null
}
const threadId = nonEmptyString(root.threadId) ?? nonEmptyString(record(root.thread)?.id)
if (!threadId) {
return null
}
const usage = record(root.tokenUsage)
const total = record(usage?.total)?.totalTokens ?? usage?.totalTokens ?? root.totalTokens
return typeof total === 'number' && Number.isFinite(total) && total >= 0
? { threadId, totalTokens: total }
: null
}
/** Pull the `subAgentActivity` item out of a raw notification payload.
*
* Lives beside the readers rather than in the translator: the translator's job
* is routing, and this is the shape check that decides whether a frame is one
* of ours at all. Returns null for anything that is not a thread item, which is
* the translator's signal to keep looking. */
export function readCodexNotificationThreadItem(
params: unknown,
read: (value: unknown) => CodexThreadItem | null
): CodexThreadItem | null {
const record =
typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {}
return read(record.item)
}