mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +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.
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
|
import {
|
|
readCodexBackgroundTaskFrame,
|
|
type CodexBackgroundTaskEvent
|
|
} from './codex-background-task-frames'
|
|
|
|
const PRIMARY = 'parent-thread'
|
|
const PARENT_TURN = 'parent-turn'
|
|
const CHILD = 'child-thread'
|
|
const CHILD_TURN = 'child-turn'
|
|
|
|
function turn(
|
|
method: 'turn/started' | 'turn/completed',
|
|
threadId: string,
|
|
turnId: string,
|
|
status = 'completed'
|
|
): CodexBackgroundTaskEvent {
|
|
return { method, threadId, params: { threadId, turn: { id: turnId, status } } }
|
|
}
|
|
|
|
function activity(
|
|
kind = 'started',
|
|
parentTurn = PARENT_TURN,
|
|
child = CHILD,
|
|
name = 'count_a'
|
|
): CodexBackgroundTaskEvent {
|
|
return {
|
|
method: 'item/started',
|
|
threadId: PRIMARY,
|
|
params: {
|
|
threadId: PRIMARY,
|
|
turnId: parentTurn,
|
|
item: {
|
|
type: 'subAgentActivity',
|
|
id: `activity-${kind}`,
|
|
kind,
|
|
agentThreadId: child,
|
|
agentPath: `/root/${name}`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function runningChild(): CodexBackgroundTaskTracker {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
|
tracker.observe(activity())
|
|
return tracker
|
|
}
|
|
|
|
function command(
|
|
threadId = PRIMARY,
|
|
method = 'item/started',
|
|
commandText = 'sleep 90'
|
|
): CodexBackgroundTaskEvent {
|
|
return {
|
|
method,
|
|
threadId,
|
|
params: {
|
|
threadId,
|
|
turnId: threadId === PRIMARY ? PARENT_TURN : CHILD_TURN,
|
|
item: {
|
|
type: 'commandExecution',
|
|
id: 'exec-1',
|
|
processId: '71831',
|
|
source: 'unifiedExecStartup',
|
|
command: commandText,
|
|
status: method === 'item/started' ? 'inProgress' : 'completed'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('readCodexBackgroundTaskFrame', () => {
|
|
it('reads activity as child metadata without inferring execution state', () => {
|
|
expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({
|
|
kind: 'subagents',
|
|
children: [{ agentThreadId: CHILD, label: 'count_a' }]
|
|
})
|
|
})
|
|
|
|
it('reads a child turn with its own execution identity', () => {
|
|
expect(readCodexBackgroundTaskFrame(turn('turn/started', CHILD, CHILD_TURN), PRIMARY)).toEqual({
|
|
kind: 'turn',
|
|
threadId: CHILD,
|
|
turnId: CHILD_TURN,
|
|
state: 'working'
|
|
})
|
|
})
|
|
|
|
it('does not register the primary thread even when its activity path is missing', () => {
|
|
const event = activity('interacted', PARENT_TURN, PRIMARY)
|
|
;(event.params as { item: { agentPath?: string } }).item.agentPath = undefined
|
|
expect(readCodexBackgroundTaskFrame(event, PRIMARY)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('CodexBackgroundTaskTracker child execution ownership', () => {
|
|
it('does not claim work from an activity item without a child turn', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(activity())
|
|
tracker.observe(activity('interacted'))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
expect(tracker.state).toBeNull()
|
|
})
|
|
|
|
it('reports an executing child while the spawning turn is still open', () => {
|
|
const tracker = runningChild()
|
|
const running = {
|
|
state: 'monitoring',
|
|
supportsStopAll: false,
|
|
tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }]
|
|
}
|
|
// The strip is a live view: a fan-out is reported while it runs, not once
|
|
// the parent turn happens to end.
|
|
expect(tracker.state).toEqual(running)
|
|
// Turn end reveals children, it never settles them; the child is unchanged.
|
|
expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(false)
|
|
expect(tracker.state).toEqual(running)
|
|
})
|
|
|
|
it('never settles a child when a primary turn ends', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
for (let index = 0; index < 300; index++) {
|
|
expect(tracker.observe(turn('turn/completed', PRIMARY, `later-${index}`))).toBe(false)
|
|
}
|
|
expect(tracker.state?.tasks).toHaveLength(1)
|
|
})
|
|
|
|
it.each(['completed', 'interrupted', 'failed'])(
|
|
'settles on the matching child turn %s',
|
|
(status) => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, status))).toBe(true)
|
|
expect(tracker.state).toBeNull()
|
|
}
|
|
)
|
|
|
|
it('does not mistake late activity completion for the current child execution', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(activity('completed'))
|
|
expect(tracker.state?.tasks).toHaveLength(1)
|
|
})
|
|
|
|
it.each([PARENT_TURN, 'followup-parent'])(
|
|
'reports follow-up work in %s using the new child turn',
|
|
(parentTurn) => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/started', PRIMARY, parentTurn))
|
|
tracker.observe(activity('interacted', parentTurn))
|
|
expect(tracker.state).toBeNull()
|
|
tracker.observe(turn('turn/started', CHILD, 'followup-child-turn'))
|
|
tracker.observe(turn('turn/completed', PRIMARY, parentTurn))
|
|
expect(tracker.state?.tasks).toHaveLength(1)
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
|
tracker.observe(activity('completed'))
|
|
expect(tracker.state?.tasks).toHaveLength(1)
|
|
tracker.observe(turn('turn/completed', CHILD, 'followup-child-turn'))
|
|
expect(tracker.state).toBeNull()
|
|
}
|
|
)
|
|
|
|
it('keeps idle send_message activity out of the strip', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(activity('interacted', 'message-parent'))
|
|
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
|
|
expect(tracker.state).toBeNull()
|
|
})
|
|
|
|
it('does not invent another execution for a message to a working child', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(activity('interacted', 'message-parent'))
|
|
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
|
|
expect(tracker.state?.tasks).toHaveLength(1)
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
expect(tracker.state).toBeNull()
|
|
})
|
|
|
|
it('retains completion delivered before child registration', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
tracker.observe(activity())
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
expect(tracker.state).toBeNull()
|
|
})
|
|
|
|
it('publishes no extra state for duplicate owner or metadata events', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
expect(tracker.observe(turn('turn/started', CHILD, CHILD_TURN))).toBe(false)
|
|
expect(tracker.observe({ ...activity(), method: 'item/completed' })).toBe(false)
|
|
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(true)
|
|
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(false)
|
|
})
|
|
|
|
it('bounds retained child history while allowing repeated completed runs', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(activity())
|
|
for (let index = 0; index < 300; index++) {
|
|
const id = `child-turn-${index}`
|
|
tracker.observe(turn('turn/started', CHILD, id))
|
|
expect(tracker.state?.tasks).toHaveLength(1)
|
|
tracker.observe(turn('turn/completed', CHILD, id))
|
|
expect(tracker.state).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('clears the roster at session teardown', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
expect(tracker.clear()).toBe(true)
|
|
expect(tracker.state).toBeNull()
|
|
expect(tracker.clear()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('CodexBackgroundTaskTracker command integration', () => {
|
|
it('reveals a surviving primary shell after its own turn ends', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
const shell = [{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }]
|
|
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
|
tracker.observe(command())
|
|
expect(tracker.state).toBeNull()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
expect(tracker.state?.tasks).toEqual(shell)
|
|
// Only the shell's own completion retires the row.
|
|
tracker.observe(command(PRIMARY, 'item/completed'))
|
|
expect(tracker.state).toBeNull()
|
|
})
|
|
|
|
it('reveals a child shell only after the child execution finishes', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(command(CHILD))
|
|
expect(tracker.state?.tasks).toHaveLength(1)
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, 'interrupted'))
|
|
expect(tracker.state?.tasks).toEqual([
|
|
{
|
|
id: `codex-command:thread:${CHILD}:exec-1`,
|
|
kind: 'command',
|
|
description: 'count_a — sleep 90'
|
|
}
|
|
])
|
|
tracker.observe(command(CHILD, 'item/completed'))
|
|
expect(tracker.state).toBeNull()
|
|
})
|
|
|
|
it('leaves a primary shell unqualified', () => {
|
|
const tracker = runningChild()
|
|
tracker.observe(command(PRIMARY))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
expect(tracker.state?.tasks).toContainEqual({
|
|
id: 'codex-command:primary:exec-1',
|
|
kind: 'command',
|
|
description: 'sleep 90'
|
|
})
|
|
})
|
|
|
|
it('keeps the command visible under a label that would otherwise fill the row', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
|
tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(600)))
|
|
tracker.observe(command(CHILD))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
const description = tracker.state?.tasks?.[0]?.description
|
|
expect(description).toContain('sleep 90')
|
|
expect(description).toBe(`${'L'.repeat(95)}… — sleep 90`)
|
|
})
|
|
|
|
it('never cuts a label mid surrogate pair', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
|
tracker.observe(activity('started', PARENT_TURN, CHILD, `${'L'.repeat(94)}\u{1F600}bad`))
|
|
tracker.observe(command(CHILD))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
|
expect(description.isWellFormed()).toBe(true)
|
|
expect(description).toBe(`${'L'.repeat(94)}… — sleep 90`)
|
|
})
|
|
|
|
it('never cuts a qualified command mid surrogate pair', () => {
|
|
// The label is bounded, then the COMPOSED row is bounded again. That second
|
|
// cut lands inside the description, so clipping only the label side leaves a
|
|
// lone surrogate — lossy through any non-JSON UTF-8 hop.
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
|
tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(96)))
|
|
// Places the pair exactly where a raw slice of the composed row splits it.
|
|
tracker.observe(command(CHILD, 'item/started', `${'C'.repeat(412)}\u{1F600}${'D'.repeat(200)}`))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
|
expect(description.length).toBeLessThanOrEqual(512)
|
|
expect(description.startsWith(`${'L'.repeat(96)} — `)).toBe(true)
|
|
expect(description.isWellFormed()).toBe(true)
|
|
})
|
|
|
|
it('never cuts an unqualified primary command mid surrogate pair', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
|
// The pair straddles the raw description bound itself.
|
|
tracker.observe(
|
|
command(PRIMARY, 'item/started', `${'C'.repeat(511)}\u{1F600}${'D'.repeat(50)}`)
|
|
)
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
|
expect(description.length).toBeLessThanOrEqual(512)
|
|
expect(description.isWellFormed()).toBe(true)
|
|
})
|
|
|
|
it('names a child shell whose label only arrives after the command', () => {
|
|
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
|
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
|
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
|
tracker.observe(command(CHILD))
|
|
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
|
tracker.observe(activity())
|
|
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
|
expect(tracker.state?.tasks).toEqual([
|
|
{
|
|
id: `codex-command:thread:${CHILD}:exec-1`,
|
|
kind: 'command',
|
|
description: 'count_a — sleep 90'
|
|
}
|
|
])
|
|
})
|
|
})
|