Files
orca/src/shared/agent-session-background-task-wire.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

96 lines
4.1 KiB
TypeScript

// The background-tasks half of the agent-session wire: the per-task row, the
// roster state that carries it, and the field equality both the client reducer
// and the host status feed compare with. Split out of `agent-session-wire.ts`
// when that file reached its line budget; the definitions below are unchanged.
import type { AgentChildWorkView } from './agent-status-child-work-view'
/** Per-task run state, reusing the agent-state vocabulary the dashboard already
* renders. Optional on the wire: an old host sends none and clients fall back
* to kind-derived defaults. */
export type AgentSessionBackgroundTaskRunState =
| 'working'
| 'monitoring'
| 'waiting'
| 'blocked'
| 'done'
| 'idle'
| 'unverifiable'
export type AgentSessionBackgroundTask = {
id: string
kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown'
description?: string
/** Provider-reported identity (e.g. a subagent type). `description` stays the
* display name; this is the fallback when the description is absent. */
name?: string
state?: AgentSessionBackgroundTaskRunState
/** Host epoch ms when the task was first observed, so clients render elapsed. */
startedAt?: number
/** Cumulative provider-reported token usage, where the provider supplies it. */
totalTokens?: number
/** Whether this row's own stop can act on it. Absent means yes: every host
* that predates this field published only backgrounded, stoppable rows, and
* a client that read absence as "not stoppable" would hide a working control
* on those hosts. A row the host cannot target sends `false`. */
stoppable?: boolean
}
export type AgentSessionBackgroundTaskState = {
state: 'monitoring'
/** Optional so mixed-version clients can consume state-only hosts. */
tasks?: AgentSessionBackgroundTask[]
/** Terminal-state siblings of a still-live roster, kept apart from `tasks`
* so old clients keep rendering exactly the live set they render today. */
settledTasks?: AgentSessionBackgroundTask[]
/** Optional so clients only send targeted stops to hosts that accept them. */
supportsTaskStop?: boolean
/** Whether an untargeted "stop everything" is available at all. Absent means
* yes: every host that predates this field accepted one, and a client that
* read absence as "no stop" would hide a working control on those hosts.
* A host whose provider exposes no honest stop sends `false`. */
supportsStopAll?: boolean
/** The host's child records for this session as views, live and recently settled; `tasks` and
* `settledTasks` are derived from them. Absent from older hosts. A reader that does not
* advertise `agent-session.background-task-child-views.v1` never receives a state whose only
* rows are settled: to it, as before, the roster is live work. */
children?: AgentChildWorkView[]
/** The host refused some of this session's child work for room, so what is listed is not all
* that runs; no row's absence means it ended. A refused child stops counting once a later
* report of it is admitted or it reports its own end; past the host's bound, and in every case
* once the provider session ends, the whole flag goes. */
incomplete?: true
}
function backgroundTaskFieldsEqual(
left: AgentSessionBackgroundTask,
right: AgentSessionBackgroundTask
): boolean {
return (
left.id === right.id &&
left.kind === right.kind &&
left.description === right.description &&
left.name === right.name &&
left.state === right.state &&
left.startedAt === right.startedAt &&
left.totalTokens === right.totalTokens &&
left.stoppable === right.stoppable
)
}
/** Field equality for task lists, shared by the client reducer and the host
* status feed so a publish whose only change is one task's state is never
* judged equal and dropped. */
export function agentSessionBackgroundTasksEqual(
left: AgentSessionBackgroundTask[] | undefined,
right: AgentSessionBackgroundTask[] | undefined
): boolean {
if (left === right) {
return true
}
if (!left || !right || left.length !== right.length) {
return false
}
return left.every((task, index) => backgroundTaskFieldsEqual(task, right[index]))
}