Files
orca/src/shared/native-chat-subagent-summary.ts
T
Brennan Benson f4068747ac fix(claude): a restarted provider continues the subagent roster earlier runs journaled (#23758)
* fix(claude): a subagent resumed after a restart keeps its canonical id

A backgrounded Claude subagent resumed by a message after its session's provider
restarted parents its frames to its ORIGINAL spawn call, while the announcement the
new provider sees names only the resuming call. The spawn call's alias lived only
in the old provider's memory, so every row the resumed child wrote fell through to
its raw call id: one child shown as two, a named roster entry with no rows and an
unnamed section holding them.

The alias table now also recalls what an earlier run of the session resolved,
re-derived from the agent rows it journaled (canonical id beside the call its frames
arrived under), read once per bound journal epoch. Nothing new is persisted.

* fix(claude): a restarted provider continues the subagent roster earlier runs journaled

The roster's state lived in one provider process while everything it writes is
the session's. After a restart a resumed child was re-rostered in a second group
row with its attempts restarted, its resumed frames lost the alias they still
carry, and the one group row no turn owns was rewritten from empty, erasing the
children an earlier run had listed there.

A run now reads what earlier runs journaled, once per bound journal epoch: group
rows give each child's entry and group, agent rows give the spawn-call aliases
and the latest attempt. A group is inherited only when this run's own events
reach it, and inheriting writes nothing. An inherited child is reopened by an
announcement exactly as an in-process resume reopens it, and by nothing else.
The alias recall is one facet of that read. Nothing new is persisted.

* fix(claude): an earlier run's subagent takes Claude's restart verdict in one row

At restart Claude reports each agent the previous session left running as
stopped ("didn't finish before the previous session ended"). The roster ignored
it, leaving the entry unverifiable, while the background-task lane, which never
saw that agent announced, opened a second, unnamed row for the same agent.

The roster now records the verdict on the inherited entry, keeping the time the
earlier run lost contact, and still reopens the entry on an announcement. The
background-task lane leaves any agent an earlier run rostered to the roster,
from the same journal-derived reading.

* fix(claude): a restart verdict on a child whose host died invents no stop time

A journal reopened after its host died leaves a child unverifiable with no stop
time. Stamping Claude's later restart verdict with the current time would show
the whole outage as how long the child ran, so the earlier run's stamp, or its
absence, is kept. The journal-liveness comment no longer describes the roster as
unable to continue from the journal.

* fix(claude): a child two journaled rows list stays live in one of them

An older build could re-roster a resumed child in a later turn's row, so two
group rows list it (67 children in 39 local sessions). Inheriting the second
row re-pointed the child to that row's copy, so the copy the resume had
reopened was left at working and swept to unverifiable, while the outcome
landed on the stale copy. The first row reached keeps the child.

* fix(native-chat): no run length for a settled child with no stop time

A subagent whose host died without sweeping it has no stop time. After a
restart, Claude's own verdict on it ("stopped") is now recorded, but the rule
that hides the group's duration only covered `unverifiable`, so a mixed group
showed the sibling's duration as the whole group's. The rule now keys on the
missing stop time, whatever the settled state.

* fix(claude): a twice-listed child resumes in the row the journal reading chose

A child an older build listed in two rows was placed in whichever row this
run's frames reached first, so a sibling's frame reaching the older row made
the resumed child reopen there. Placement now follows the reading's one
tie-break, and evicting a row drops only placements that point at it.

* fix(native-chat): a resumed subagent's clock never counts the idle gap

A reopened Claude child now starts a new run: its startedAt is reset to the
reopening frame's time on every reopen path (resume after a restart and a
same-process reactivation). The group clock reads the union of the children's
latest runs, so an idle gap between runs is never counted; an ordinary
overlapping fan-out reads the same as before.
2026-09-29 23:47:48 -07:00

246 lines
11 KiB
TypeScript

// One spawn group's roster → the numbers a single flat row needs.
//
// Shared because the producer and the desktop transcript must agree on what
// "N working" means: the producer uses the same terminal predicate the renderer
// does, so a state that reads terminal here latches terminal there. Mobile has
// no roster renderer — it shows only the write-time-frozen fallback sentence,
// which is why that sentence is built from this same summary, and why the
// sentence itself may claim nothing that a later reader cannot still verify.
import {
isSubagentGroupBlock,
type NativeChatBlock,
type NativeChatSubagentEntry,
type NativeChatSubagentGroupBlock,
type NativeChatSubagentState
} from './native-chat-types'
/** Every state a child cannot leave. `working` is the only in-flight state:
* providers report several (started/interacted, pending/running/paused) and the
* producer collapses them before the roster is written. */
const TERMINAL_SUBAGENT_STATES: ReadonlySet<string> = new Set([
'idle',
'completed',
'failed',
'stopped',
'unverifiable'
])
/** Settled-state precedence for the group's one-line verdict: the worst
* outcome wins, and `completed` only shows when nothing else is left. */
const SETTLED_PRECEDENCE = ['failed', 'stopped', 'unverifiable', 'idle', 'completed'] as const
/** Outcomes that must be visible immediately, not held back until the last
* sibling stops working: a fan-out with a dead child is not a neutral row. */
const ADVERSE_PRECEDENCE = ['failed', 'stopped', 'unverifiable'] as const
/** A state this build does not know reads as `unverifiable`, never as working:
* a roster written by a newer build must not leave the row spinning forever. */
export function normalizeSubagentState(state: string): NativeChatSubagentState {
if (state === 'working') {
return 'working'
}
return TERMINAL_SUBAGENT_STATES.has(state) ? (state as NativeChatSubagentState) : 'unverifiable'
}
/** Bound on the per-child provider strings a roster row carries — `id` and
* `label`. One constant because the producer writes a durable row and both
* readers clip it again: a larger producer bound is bytes every consumer throws
* away, replayed on every reconnect.
*
* `groupId` is deliberately NOT bounded by the producer: the row's durable
* identity is `codex-subagents:${groupId}` and cannot be clipped without
* changing which row a replay finds, so bounding only the block field would
* save nothing and make the two disagree. Both readers still clip it. */
export const MAX_SUBAGENT_FIELD_CHARS = 512
export function isTerminalSubagentState(state: string): boolean {
return normalizeSubagentState(state) !== 'working'
}
/** The child's own verdict about itself. `unverifiable` is deliberately absent:
* it records that we stopped being able to see the child, not what it did, so
* a later authoritative report must still be able to correct it. */
const LATCHED_SUBAGENT_STATES: ReadonlySet<string> = new Set([
'idle',
'completed',
'failed',
'stopped'
])
/** Whether `next` may replace `current`.
*
* A child that reported its own outcome keeps it. A child we merely lost sight
* of may still settle: the session sweep marks live children `unverifiable`,
* and contact can return before the row is read — latching the sweep would
* report a child that finished as one we never saw finish.
* The reverse is refused: nothing returns to `working` once we have given up on
* it, so a straggler progress tick cannot re-light a settled row. */
export function canReplaceSubagentState(current: string, next: string): boolean {
const from = normalizeSubagentState(current)
if (from === 'working') {
return true
}
if (LATCHED_SUBAGENT_STATES.has(from)) {
return false
}
// `from` is `unverifiable`: only a real verdict may land.
return LATCHED_SUBAGENT_STATES.has(normalizeSubagentState(next))
}
export type NativeChatSubagentSummary = {
total: number
working: number
/** The group's verdict once nothing is in flight; null while any child works. */
settledState: NativeChatSubagentState | null
/** How many children hold `settledState`. */
settledCount: number
/** Worst adverse outcome already recorded, reported even while siblings still
* work. Null when nothing has gone wrong. */
adverseState: NativeChatSubagentState | null
/** How many children hold `adverseState`. */
adverseCount: number
/** Sum of the latest per-child totals. Null when no child reported one.
* Children's counters are disjoint from the parent's, so this never
* double-counts — and the parent's own usage is deliberately excluded. */
tokens: number | null
/** Where the elapsed clock starts so that, run to now or to `settledAt`, it
* reads the time the children's latest runs cover: overlaps once, idle gaps
* not at all. Not a real start time. Null when no run can be measured. */
clockStartedAt: number | null
/** Latest terminal timestamp, once the group has settled. */
settledAt: number | null
}
export function summarizeSubagentGroup(
agents: readonly NativeChatSubagentEntry[]
): NativeChatSubagentSummary {
const counts = new Map<NativeChatSubagentState, number>()
let working = 0
let tokens: number | null = null
let settledAt: number | null = null
for (const agent of agents) {
const state = normalizeSubagentState(agent.state)
if (state === 'working') {
working += 1
} else {
counts.set(state, (counts.get(state) ?? 0) + 1)
}
if (typeof agent.tokens === 'number' && Number.isFinite(agent.tokens)) {
tokens = (tokens ?? 0) + agent.tokens
}
if (typeof agent.settledAt === 'number') {
settledAt = settledAt === null ? agent.settledAt : Math.max(settledAt, agent.settledAt)
}
}
const settledState =
working > 0 ? null : (SETTLED_PRECEDENCE.find((state) => counts.has(state)) ?? null)
const adverseState = ADVERSE_PRECEDENCE.find((state) => counts.has(state)) ?? null
return {
total: agents.length,
working,
settledState,
settledCount: settledState === null ? 0 : (counts.get(settledState) ?? 0),
adverseState,
adverseCount: adverseState === null ? 0 : (counts.get(adverseState) ?? 0),
tokens,
clockStartedAt: subagentClockStart(agents, working > 0 ? null : settledAt),
settledAt: working > 0 ? null : settledAt
}
}
/** Every working run ends at now, so together they cover one span from the
* earliest of them; only settled time before that span adds to it. */
function subagentClockStart(
agents: readonly NativeChatSubagentEntry[],
settledAt: number | null
): number | null {
const runs: [number, number][] = []
let liveFrom: number | null = null
for (const agent of agents) {
if (typeof agent.startedAt !== 'number') {
continue
}
if (normalizeSubagentState(agent.state) === 'working') {
liveFrom = liveFrom === null ? agent.startedAt : Math.min(liveFrom, agent.startedAt)
} else if (typeof agent.settledAt === 'number') {
runs.push([agent.startedAt, Math.max(agent.startedAt, agent.settledAt)])
}
}
const cutoff = liveFrom ?? Infinity
let covered = 0
let reach = -Infinity
for (const [start, end] of runs.sort((a, b) => a[0] - b[0])) {
covered += Math.max(0, Math.min(end, cutoff) - Math.max(start, reach))
reach = Math.max(reach, end)
}
if (liveFrom !== null) {
return liveFrom - covered
}
return settledAt === null || runs.length === 0 ? null : settledAt - covered
}
/** A childless group draws nothing: `NativeChatSubagentRun` renders null for one,
* so no caller may count it as renderable. The block schema admits `agents: []`
* though no producer writes it, and a row that passes a renderable check while
* drawing nothing still costs the transcript a gap slot. */
export function isRenderableSubagentGroup(block: NativeChatSubagentGroupBlock): boolean {
return block.agents.length > 0
}
/** The spawn groups in `blocks` that will actually draw a row. */
export function subagentGroupBlocks(
blocks: readonly NativeChatBlock[]
): NativeChatSubagentGroupBlock[] {
return blocks.filter(
(block): block is NativeChatSubagentGroupBlock =>
isSubagentGroupBlock(block) && isRenderableSubagentGroup(block)
)
}
/** Plain-text stand-in for the roster, frozen into the journal at write time for
* clients without the block type.
*
* It states only what stays true once the writing process is gone: the group was
* spawned, and whatever outcome had already latched. It deliberately carries NO
* live count. The row is durable and replayed on every reconnect, and the
* clients that read this sentence instead of the block reconcile nothing and
* cannot re-check the children — so a frozen `N working` would go on asserting
* a liveness only the dead process could have observed. That is the collapse
* `docs/reference/ssh-execution-boundary.md` forbids: loss of contact is not
* evidence of a live state. Liveness stays with the structured block, which the
* writing host revises in place for as long as it can see the children.
*
* `Kicked off` vs `Ran` is kept, and is not a liveness claim: it reports
* whether an outcome had been recorded when the row was written. Saying `Ran`
* while children were in flight would assert they exited, which is the same
* error in the other direction.
*
* The adverse count stays: a reader that only ever sees this sentence must not
* be told a failing fan-out is fine. */
export function subagentGroupFallbackText(agents: readonly NativeChatSubagentEntry[]): string {
const { total, working, adverseState, adverseCount } = summarizeSubagentGroup(agents)
const noun = total === 1 ? 'subagent' : 'subagents'
const adverse = adverseState === null ? '' : ` (${adverseCount} ${adverseState})`
return `${working > 0 ? 'Kicked off' : 'Ran'} ${total} ${noun}${adverse}`
}
/** Whether `text` is a roster block's frozen twin rather than ordinary prose.
* Shape-matched, not recomputed: a roster written by a newer build can hold a
* state this build normalizes to `unverifiable`, so its twin never equals the
* sentence recomputed here — and a byte compare would then print the roster
* twice.
*
* The `— N working` clause is LEGACY. The twin carried a live count only while
* this feature was unreleased, so the rows holding one are dev journals of this
* branch rather than anything shipped — but those replay forever too, and each
* would print twice without this branch. It costs no false-positive surface the
* bare shape does not already carry, so it stays until such journals no longer
* matter. Keep in sync with `subagentGroupFallbackText`. */
const SUBAGENT_GROUP_FALLBACK_PATTERN =
/^(?:Kicked off \d+ subagents?(?: — \d+ working)?|Ran \d+ subagents?)(?: \(\d+ [a-z][a-z-]*\))?$/
export function isSubagentGroupFallbackText(text: string): boolean {
return SUBAGENT_GROUP_FALLBACK_PATTERN.test(text)
}