mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* 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.
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
// One spawn group's roster → the numbers a single flat row needs.
|
|
//
|
|
// Shared because the producer and every transcript (desktop and the phone) must
|
|
// agree on what "N working" means: the producer uses the same terminal predicate
|
|
// the renderers do, so a state that reads terminal here latches terminal there.
|
|
// A client without the block type 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 subagent row carries — `id`, `label` and
|
|
* `groupId`. One constant because the host writes a durable row and both readers clip it again:
|
|
* a larger write bound is bytes every consumer throws away, replayed on every reconnect. */
|
|
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)
|
|
)
|
|
}
|
|
|
|
/** Whether `block` is the text twin of a roster the row draws (`drawsGroup`, from
|
|
* `subagentGroupBlocks`): only a drawn roster replaces its sentence, and real text beside a group
|
|
* is never matched. The one twin rule every transcript applies. */
|
|
export function isReplacedSubagentGroupTwin(block: NativeChatBlock, drawsGroup: boolean): boolean {
|
|
return drawsGroup && block.type === 'text' && isSubagentGroupFallbackText(block.text)
|
|
}
|
|
|
|
/** The row's blocks less any roster twin a drawn group replaces. */
|
|
export function withoutSubagentGroupTwins(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
|
|
const drawsGroup = subagentGroupBlocks(blocks).length > 0
|
|
return blocks.filter((block) => !isReplacedSubagentGroupTwin(block, drawsGroup))
|
|
}
|
|
|
|
/** 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)
|
|
}
|