Report on idle
A plan for subagents that tell their parent every time they go idle, not just the first time, and that keep their promise across a crash.
Today a subagent tells its parent about its first answer and then goes quiet. This plan makes every busy period of a child session end in exactly one <subagent> report, written in the same SQLite transaction that marks the child idle. A crash can delay a report. It can no longer lose one.
Every figure below runs the same small model of the system: sessions, busy periods, time_suspended claims, job rows, the parent's inbox and the per-session sequence counter. They are meant to be poked. Start with the first one.
Try: press Spawn child (background), then Child starts a background shell. When the child goes idle, press Shell exits. Or pick one of the examples.
Every action runs in two copies of the system at once, one behaving like dev today and one following the plan, so switching the toggle never changes what happened, only who heard about it. On dev only a job's own busy period has an observer waiting for it; the turn a shell wakes, or one you start by typing into the child, ends with nobody listening.
1Today on dev: one report, then silence
When a parent spawns a subagent, SubagentJob.start registers a job whose run resumes the child session and reads its answer. Reporting back is a separate fiber: SubagentJob.notify forks one observer per job generation, keyed ${childSessionID}:${startedAt}. The observer calls jobs.wait({ id }) once, hands the result to SubagentCompletion.deliver, and deletes its key.
That covers the job's own busy period. But a child session outlives its job. You can type into it, and a background shell it started can exit and wake it. Each of those starts a new busy period that ends with an answer, and none of them has an observer. The child finishes; the parent never hears. (A follow-up call through the subagent tool starts a new job generation, so that one is reported.)
There is a quieter problem too. The answer is read with sessions.messages({ order: "desc", limit: 20 }), and the newest completed assistant message wins. Nothing ties that message to the busy period that just ended, so a busy period that produced no answer reports an older one. Section 3 fixes both.
2One sender, at the end of every busy period
The plan moves reporting out of the job layer and into session core. SessionRunCoordinator calls its settled hook exactly once per busy period, after the last coalesced drain: the moment the child went idle. SessionExecution uses that hook, through a new module SubagentReport, to send the parent one synthetic steer message:
<subagent sessionID="ses_child" state="completed|error|cancelled" description="Audit routes"> …the answer from this busy period… </subagent>
What started the busy period doesn't matter. A job's first run, a human follow-up, a re-prompt from the parent and a shell wake all end in the same hook, so they all report the same way.
There is one live sender. The jobs.wait observer in SubagentJob.notify is deleted, so nothing can race it or send twice. SessionRestart stays, but only to replay reports that a crash interrupted, and it no longer forks a jobs.wait observer of its own.
Report now. Report again later.
The report never waits for background work. If the child, or anything below it, still has shells, image jobs or nested subagents running when it settles, the report goes out immediately with a list appended:
<subagent sessionID="ses_child" state="completed" description="Audit routes">
Started the test suite in the background.
Background work still running:
- shell (sh_1): bun test
- image (openai/gpt-image-1): /tmp/out.png
- subagent (explore, sessionID: ses_abc): Audit routes
</subagent>
The list is a snapshot from SessionBackground.outstanding, which now also counts descendant sessions that are still draining (coordinator.draining). It is added for all three states, completed, error and cancelled. When nothing is running, the body is byte-for-byte what dev sends today, both for synthetic notices and for a foreground call's inline tool output.
When the shell exits, its notice wakes the child. That is a new busy period, so it settles into a second report of its own with a fresh notification ID. In Figure 1, that is the second card in the parent's inbox.
3Which answer belongs to this busy period
Every durable publish stamps a per-session sequence number. When coordinator.started publishes Execution.Started, SubagentReport keeps startedSeq = event.durable.seq in an in-memory map. At settlement, SessionStore.lastAssistantAfter({ sessionID, afterSeq: startedSeq }) returns the newest completed, error-free assistant message with seq > startedSeq. It pages backwards 20 rows at a time, so a long busy period can't push its answer out of view.
Try: drag the dark Execution.Started row up or down (or focus it and use the arrow keys), add rows at the bottom, then switch to the Control-only and Same millisecond examples.
Rows below the dark line belong to this busy period. The blue row is what the parent is told. With nothing new below the line, a busy period with no job waiting sends nothing at all, and a background job gets Subagent completed without a text response. so its promised row clears. The rule switch shows what dev picks instead, and why a timestamp boundary can't tell two events in the same millisecond apart while a sequence number always can.
Why not read the event log? Bus.configured({ persist }) defaults to false (bus.ts:214); only workerd persists events. What every durable publish does write, in one BEGIN IMMEDIATE transaction, is the event_sequence counter and every projector, and the message projector stamps each session_message row with the event's seq. The boundary lives on rows that exist in every deployment.
Why can startedSeq stay in memory? If the server dies before the terminal publish commits, the busy period never settled: time_suspended is still set, and a resumed child starts a new busy period with a fresh startedSeq. If it dies after the commit, the rendered report is already in the job row, and startedSeq is never read again.
4Foreground, background or neither
A child runs under a blocking tool call (foreground: the tool returns the answer inline), or detached (background: the parent was promised a notice under a specific notificationID), or with no job at all (a shell wake, a human prompt). Core has to know which before it writes anything, so the first thing coordinator.settled does, before jobs.cancel, is call Job.prepareSubagentReport(sessionID, boundGeneration):
| Mode | When | What core does |
|---|---|---|
foreground | A job owned by a blocking tool call | No outbox row, no synthetic message: the tool returns inline. Marks the job settling. |
background | A backgrounded job from this busy period | Reuses the job's notificationID, say msg_7 |
none | No job: a shell wake, a human prompt | Mints a new notificationID at commit |
Classifying first opens small windows where something else touches the same job. Three in-memory flags on Job.Active close them:
settlingis set when a running job is classified. A late Ctrl+B is refused andJob.startwon't join the dying generation.consumedis set bycompleteBackground.Job.settleruns one tick after the report was delivered and must not write the deleted row back, so it only persists when!settling && !consumed.reportedmakes classification happen at most once per generation. A job cancelled on the creation tick, beforestartedran, still matches by generation instead of being reported twice under two IDs.
Try: drag the dark event in the right lane to just after prepareSubagentReport, then turn the guard off. Switch to the second scenario and do the same with Job.settle.
The left lane is the child's coordinator.settled hook; the right lane is the other fiber. Each row shows the job's state after that step, computed by the same model as every other figure. The first scenario is a real race: its outcome depends on where the right-lane event lands. The second is not a race at all. settled always finishes before SubagentJob.run returns, so Job.settle always comes last, which is exactly the order that would resurrect the row without the guard.
Background registration is atomic too. Job.start({ background: true }) marks the job backgrounded, mints its notificationID and writes the running row before the run is forked, so a fast child can't settle while it still looks foreground. And the subagent tool passes resume: false to sessions.prompt whenever the child isn't running, so the job is registered before the child's busy period can start.
5One ID, one outbox, one transaction
A report has one identity from the moment it is owed until the parent's inbox admits it: its notificationID. A background job is promised msg_7 when its row is written as running, and every path (live settle, cancel, crash replay) delivers under that ID. A busy period without a job mints its ID when the report is written. Redelivery is therefore harmless: SessionInbox.admit recognises a repeat msg_7 and drops it.
The obvious design, publishing the child's terminal event and the parent's InboxEnqueued together, can't work. bus.publishAll requires every event in one call to belong to the same aggregate, guarded by a per-aggregate lock, and the child and parent are different aggregates.
So the job table becomes a transactional outbox. The terminal bus.publish(Succeeded | Failed | Interrupted, …, { commit }) already runs in one BEGIN IMMEDIATE transaction, and every db.* call inside commit(seq) lands on the same connection and in the same commit:
projectIdlewritestime_idleandidle_outcomeonsession_v2.store.releasesetstime_suspended = nullandresume_attempts = 0.- The child and parent rows are read. If this isn't a child,
notifyis off, or the parent is gone, a background row is deleted and nothing else is written. - The body is resolved: the answer from
lastAssistantAfter, or the error, orSubagent cancelled, plus the still-running list. A message that fails to decode degrades to an error report, so the release still commits. - The job row is upserted with its terminal status and the rendered body.
A child can never commit as idle without its report being durably owed. After the commit, and outside both the lock and the transaction, core calls SessionInbox.admit on the parent with id = notificationID, then jobs.completeBackground(notificationID), then coordinator.wake(parentID) unless the parent has a staged revert.
Lock order. Job takes its in-memory map first and writes SQLite inside it. commit(seq) already owns the aggregate lock and the SQLite write lock, so touching the job map there would invert the order and deadlock. The rule: classify in memory before bus.publish, do only flat db.* reads and writes inside commit(seq) (never db.transaction, bus.publish or inbox.admit), and deliver after it returns.
6SIGKILL anywhere
One background report goes through about ten steps across two sessions, most of them writes. The process can die between any two of them. What is on disk at that instant decides what boot does, and the goal is the same in every window: the parent ends up with the report exactly once.
Try: drag the orange SIGKILL marker between Mark the child idle and Write the report row. Inside one transaction nothing is lost: SQLite rolls both writes back. Now switch to Two separate writes.
The left rail is coloured by what happens if the process dies in that gap, so the whole map is visible at once. The right side shows exactly what is on disk at the kill (green marks what the last commit wrote), what memory was lost, and what SessionRestart does with it on boot. With two separate writes, one gap turns orange: the child is idle on disk and nothing says a report is still owed. That gap is what the outbox closes.
Boot runs in three phases. Phase 1 reads every pending job row: rows with a terminal status are delivered as-is with no model turn, through a deliverSafe that replaces Effect.orDie so one bad row can't abort the sweep; running rows are counted against the child's resume budget. Phase 2 resumes suspended top-level sessions. Phase 3 only then starts the resumable children, so a fast child can't wake its parent before the parent's own budget was counted.
- Resume budget exhausted.
prepareResumerefuses, publishesExecution.Failed, and boot delivers anerrorreport (or just clears the row if the child is silent). - Deleted parent.
Session.NotFoundErroror a foreign-key failure on admit clears the row. - ID conflict. A
SyntheticConflictErrorlogs a warning and keeps the row; the sweep moves on. - Graceful shutdown is not a cancel. Shutdown keeps
time_suspendedand therunningrow.SubagentJob.runonly callsexecution.interrupt(child)when the job was cancelled by its caller (cancelledByCaller), so a restart never sends fakecancelledreports. - Deferred, out of scope for now. A mid-turn crash of a child with no job row (a human typing into it, or a turn woken by a shell). On disk that looks exactly like a foreground child whose parent will retry the tool call, so
releaseChildClaimsclears it, asdevdoes. Once such a turn settles, its report is crash-safe like any other. Pick Woken by a shell in Figure 4 to see both halves.
7Silence: notify: false
The subagent tool gains a notify input. A child with notify off writes no outbox row and sends nothing when it settles, but it still receives its own children's reports. The rules don't depend on where the flag is stored:
- New children default to
notify = true; only an explicitfalsesilences a link. - The setting is never inherited by grandchildren.
- A follow-up subagent call that passes
sessionIDkeeps the child's current setting unless it passesnotifyexplicitly. - Forks have
parent_id = null, sonotifyis irrelevant to them.
Try: press Finish every turn. Then select Run benchmarks, press Client calls setMetadata, and compare options A and B.
A report wakes its parent, which finishes a turn of its own and reports one level up, unless that link is silent. The grandchild under a silent session still reports to it. Both storage options replay the same actions; the only place they disagree is a client replacing the metadata map, which wipes the key under option A.
Where the flag lives is the one open decision. Option A stores it as session_v2.metadata["subagent.notify"] = false: no migration, but metadata is otherwise host and client data that core never reads, Session.setMetadata replaces the whole map, and Session.create copies the parent's metadata, so SubagentMetadata.inherit has to strip the key. Option B adds a nullable parent_notify column next to parent_id: one additive migration (ALTER TABLE session_v2 ADD parent_notify integer;), typed on Session.Info, set at creation, never copied to children and out of setMetadata's reach. It also matches the parent: { id, notify } shape in #62036.
8Edge cases
- Busy parent. The report is admitted as a steer. The running drain picks it up, and
coordinator.wakejust rings the doorbell so the parent drains again before it settles. - Staged revert on the parent. Admit, but don't wake. The parent sees the report once the revert is resolved.
- Nested subagents. Each level reports to its own parent only. Because
outstandingincludes draining descendants, a middle session's report lists grandchildren that are still running. Settling, cancelled or foreground-blocking grandchildren are never listed. - Foreground child that leaves a shell running. The inline tool output carries the still-running list. When the shell later wakes the child, that busy period has no job, so it reports as a synthetic message.
- Interrupted on the creation tick. If a tool call is interrupted during the coordinator's first yield,
startednever runs.reportedand the generation check keep this from producing a phantom notice. - Control-only busy periods.
Session.move, a manual compact,revert.clearand an empty wake leave no assistant message afterstartedSeq. With no job waiting, nothing is sent. - Idle continuation in the background. The tool passes
resume: falsefor every idle continuation, not just foreground ones, so a wake and a forced resume can't run the model twice.
9The module map
One background subagent, end to end, and the same path after a crash.
Try: step through with Next or the arrow keys, or click any step. Hover or focus a module to see what changes in it.
Lit modules take part in the current step; green tables are written by it. Blue badges mark new modules, and the observer inside SubagentJob is the one piece that goes away.
10Scope and waves
Seven tasks in four waves, plus one follow-up pull request. Tasks inside a wave can run in parallel; each wave builds on the ones before it.
Try: hover, focus or tap a task. Blue outlines mark the tasks it builds on.
Related pull requests
- #62035 runs subagents in the background by default; with one live sender left, it has nothing to coordinate.
- #62036 adds
Session.spawnandsession.wait; spawned children report through the samesettledhook, and itsparent: { id, notify }matches option B.
Decisions recorded
- Never delay a report; append the still-running list instead.
- One live sender,
SessionExecution. The observer is deleted;SessionRestartonly recovers. startedSeqstays in memory.- The job table is the outbox, written inside
commit(seq), and delivery happens after the commit. - The guards against a late Ctrl+B and against
Job.settlerewriting the row ship in this work, not later. - A mid-turn crash of a child with no job row is deferred.
- Still open:
notifyin metadata (A) or aparent_notifycolumn (B).