mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* test(native-chat): pin each agent's subagent row writes before unifying the rosters * refactor(native-chat): one shared subagent tracker owns every agent's subagent rows Claude, Codex and ACP (Grok) each kept their own subagent roster with drifted rules. They now only report what they observe; one tracker decides grouping, runs, names, state, sweeps, retention, row writes and restart inheritance. The golden row-write tests from the previous commit show each rule change. * fix(native-chat): one entry per subagent, derived lookups, ACP end stamped in its session-end write Review round 1: a resumed child keeps its one entry and reopens it where it is listed (the chat draws one section per child, anchored at the first row naming it), every lookup is read from the rows, the no-turn row is never released, and ACP's session end stamps its live children inside the session-end write instead of writing rows ahead of it. * fix(native-chat): derive the no-turn group, end a foreground run with the turn that started it Review round 2: the row no turn owns is recognised from its id (a stored flag was lost when an earlier run's row was first reached through one of its children), and a turn's end settles the foreground runs it started wherever their child is listed. * fix(native-chat): count an inherited subagent row as settled history when a lookup loads it A read-only lookup could load an earlier run's settled row that retention never saw, so it was never released and a fresh settled row was released in its place.
249 lines
7.9 KiB
TypeScript
249 lines
7.9 KiB
TypeScript
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
|
|
import {
|
|
MAX_SUBAGENT_FIELD_CHARS,
|
|
canReplaceSubagentState
|
|
} from '../../shared/native-chat-subagent-summary'
|
|
|
|
const MAX_CHILDREN = 128
|
|
const MAX_SETTLED_TURNS = 256
|
|
/** Turn ordinals remembered per child; a row from an older run reads as its first. */
|
|
const MAX_TURN_ORDINALS_PER_CHILD = 64
|
|
|
|
export type CodexChildExecution = {
|
|
turnId: string
|
|
state: NativeChatSubagentState
|
|
}
|
|
|
|
export type CodexExecutionChild = {
|
|
agentThreadId: string
|
|
registered: boolean
|
|
label: string | null
|
|
parentTurnId: string | null
|
|
/** The thread whose stream carried this child's `started` activity. Codex
|
|
* emits that item on the spawning agent's own session, so it names the parent. */
|
|
spawnerThreadId: string | null
|
|
execution: CodexChildExecution | null
|
|
/** Which run each observed turn was, in the order the child's turns began. */
|
|
turnOrdinals: Map<string, number>
|
|
turnCount: number
|
|
}
|
|
|
|
/** Told when a child's current execution changes, whichever frame changed it. */
|
|
export type CodexExecutionListener = (child: Readonly<CodexExecutionChild>) => void
|
|
|
|
/** Child turn events own execution; activity items only identify the child. */
|
|
export class CodexSubagentExecutions {
|
|
private readonly children = new Map<string, CodexExecutionChild>()
|
|
private readonly settledTurns = new Map<string, NativeChatSubagentState>()
|
|
private readonly listeners = new Set<CodexExecutionListener>()
|
|
|
|
/** Every reader of a child's execution follows it here, so none needs the frames that end it. */
|
|
onExecutionChanged(listener: CodexExecutionListener): () => void {
|
|
this.listeners.add(listener)
|
|
return () => this.listeners.delete(listener)
|
|
}
|
|
|
|
register(
|
|
agentThreadId: string,
|
|
label: string | null,
|
|
parentTurnId: string | null | undefined,
|
|
spawnerThreadId?: string
|
|
): CodexExecutionChild | undefined {
|
|
const child = this.child(agentThreadId)
|
|
if (!child) {
|
|
return undefined
|
|
}
|
|
if (!child.registered || parentTurnId !== undefined) {
|
|
child.parentTurnId = parentTurnId ?? null
|
|
}
|
|
// A child is spawned once; its announcement is delivered twice, never by another thread.
|
|
child.spawnerThreadId ??= spawnerThreadId ?? null
|
|
child.registered = true
|
|
// Retain one overflow unit so the journal can append its per-row truncation marker.
|
|
child.label ??=
|
|
label
|
|
?.trim()
|
|
.replace(/\s+/g, ' ')
|
|
.slice(0, MAX_SUBAGENT_FIELD_CHARS + 1) || null
|
|
return child
|
|
}
|
|
|
|
observeTurn(
|
|
agentThreadId: string,
|
|
turnId: string,
|
|
state: NativeChatSubagentState
|
|
): { child: CodexExecutionChild; execution: CodexChildExecution } | null {
|
|
const key = JSON.stringify([agentThreadId, turnId])
|
|
const settled = this.settledTurns.get(key)
|
|
if (state === 'working' && settled !== undefined) {
|
|
return null
|
|
}
|
|
const child = this.child(agentThreadId)
|
|
if (!child) {
|
|
return null
|
|
}
|
|
this.numberTurn(child, turnId)
|
|
if (
|
|
state === 'working' &&
|
|
child.execution?.turnId === turnId &&
|
|
child.execution.state !== 'working'
|
|
) {
|
|
return null
|
|
}
|
|
// A turn's first outcome latches, except lost contact: a later verdict still says how it ended.
|
|
const execution = {
|
|
turnId,
|
|
state: settled !== undefined && !canReplaceSubagentState(settled, state) ? settled : state
|
|
}
|
|
if (state !== 'working') {
|
|
this.settledTurns.set(key, execution.state)
|
|
while (this.settledTurns.size > MAX_SETTLED_TURNS) {
|
|
const oldest = this.settledTurns.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
this.settledTurns.delete(oldest)
|
|
}
|
|
}
|
|
if (state === 'working' || !child.execution || child.execution.turnId === turnId) {
|
|
child.execution = execution
|
|
this.changed(child)
|
|
}
|
|
return { child, execution }
|
|
}
|
|
|
|
/** The child's thread closed with no `turn/completed`: the turn it was running ended, and how
|
|
* it went is unknown. A child running none has nothing to end. */
|
|
closeThread(agentThreadId: string): void {
|
|
const current = this.children.get(agentThreadId)?.execution
|
|
if (current?.state === 'working') {
|
|
this.observeTurn(agentThreadId, current.turnId, 'unverifiable')
|
|
}
|
|
}
|
|
|
|
/** Survives the child's turn, so a row outliving that turn can still name it. */
|
|
label(agentThreadId: string): string | null {
|
|
return this.children.get(agentThreadId)?.label ?? null
|
|
}
|
|
|
|
spawnerOf(agentThreadId: string): string | null {
|
|
return this.children.get(agentThreadId)?.spawnerThreadId ?? null
|
|
}
|
|
|
|
/** Which run of the child a turn was: 1 for the turn it was spawned into, then
|
|
* one more per follow-up turn. Null when the turn was never observed. */
|
|
turnOrdinal(agentThreadId: string, turnId: string): number | null {
|
|
return this.children.get(agentThreadId)?.turnOrdinals.get(turnId) ?? null
|
|
}
|
|
|
|
/** The child as last observed, without creating one. */
|
|
find(agentThreadId: string): Readonly<CodexExecutionChild> | undefined {
|
|
return this.children.get(agentThreadId)
|
|
}
|
|
|
|
/** The child and every child it spawned, at any depth. */
|
|
lineage(agentThreadId: string): CodexExecutionChild[] {
|
|
const lineage: CodexExecutionChild[] = []
|
|
const seen = new Set<string>()
|
|
const pending = [agentThreadId]
|
|
for (let id = pending.pop(); id !== undefined; id = pending.pop()) {
|
|
const child = this.children.get(id)
|
|
if (!child || seen.has(id)) {
|
|
continue
|
|
}
|
|
seen.add(id)
|
|
lineage.push(child)
|
|
for (const spawned of this.children.values()) {
|
|
if (spawned.spawnerThreadId === id) {
|
|
pending.push(spawned.agentThreadId)
|
|
}
|
|
}
|
|
}
|
|
return lineage
|
|
}
|
|
|
|
workingChildren(): CodexExecutionChild[] {
|
|
return [...this.children.values()].filter(
|
|
(child) => child.registered && child.execution?.state === 'working'
|
|
)
|
|
}
|
|
|
|
settleSession(): void {
|
|
for (const child of this.children.values()) {
|
|
if (child.execution?.state === 'working') {
|
|
child.execution = { ...child.execution, state: 'unverifiable' }
|
|
this.changed(child)
|
|
}
|
|
}
|
|
}
|
|
|
|
clear(): void {
|
|
this.children.clear()
|
|
this.settledTurns.clear()
|
|
}
|
|
|
|
/** Retention bounds are not observable through the child/turn API, so expose the two counts. */
|
|
retentionSizes(): { children: number; settledTurns: number } {
|
|
return { children: this.children.size, settledTurns: this.settledTurns.size }
|
|
}
|
|
|
|
private changed(child: CodexExecutionChild): void {
|
|
for (const listener of this.listeners) {
|
|
listener(child)
|
|
}
|
|
}
|
|
|
|
private child(agentThreadId: string): CodexExecutionChild | undefined {
|
|
const existing = this.children.get(agentThreadId)
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
if (this.children.size >= MAX_CHILDREN) {
|
|
const settled = [...this.children].find(([, child]) => child.execution?.state !== 'working')
|
|
if (!settled) {
|
|
return undefined
|
|
}
|
|
this.children.delete(settled[0])
|
|
}
|
|
const child: CodexExecutionChild = {
|
|
agentThreadId,
|
|
registered: false,
|
|
label: null,
|
|
parentTurnId: null,
|
|
spawnerThreadId: null,
|
|
execution: null,
|
|
turnOrdinals: new Map(),
|
|
turnCount: 0
|
|
}
|
|
this.children.set(agentThreadId, child)
|
|
return child
|
|
}
|
|
|
|
private numberTurn(child: CodexExecutionChild, turnId: string): void {
|
|
if (child.turnOrdinals.has(turnId)) {
|
|
return
|
|
}
|
|
child.turnCount += 1
|
|
child.turnOrdinals.set(turnId, child.turnCount)
|
|
if (child.turnOrdinals.size > MAX_TURN_ORDINALS_PER_CHILD) {
|
|
const oldest = child.turnOrdinals.keys().next().value
|
|
if (oldest !== undefined) {
|
|
child.turnOrdinals.delete(oldest)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function codexChildTurnState(status: unknown): NativeChatSubagentState {
|
|
if (status === 'completed') {
|
|
return 'completed'
|
|
}
|
|
if (status === 'interrupted') {
|
|
return 'stopped'
|
|
}
|
|
if (status === 'failed') {
|
|
return 'failed'
|
|
}
|
|
return 'unverifiable'
|
|
}
|