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.

anomalyco/opencodecompared with dev at 5ccb5878packages/core/src/session

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.

Live, new or delivered Crash, lost or removed Committed to disk Inactive or out of scope
Figure 1A parent and one child. Drive it, then flip between dev and the plan.

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.

Figure 2The child's message log, and the line that decides what gets reported.

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):

ModeWhenWhat core does
foregroundA job owned by a blocking tool callNo outbox row, no synthetic message: the tool returns inline. Marks the job settling.
backgroundA backgrounded job from this busy periodReuses the job's notificationID, say msg_7
noneNo job: a shell wake, a human promptMints 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:

Figure 3Race lab: reorder two fibers and switch the guards off.

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:

  1. projectIdle writes time_idle and idle_outcome on session_v2.
  2. store.release sets time_suspended = null and resume_attempts = 0.
  3. The child and parent rows are read. If this isn't a child, notify is off, or the parent is gone, a background row is deleted and nothing else is written.
  4. The body is resolved: the answer from lastAssistantAfter, or the error, or Subagent cancelled, plus the still-running list. A message that fails to decode degrades to an error report, so the release still commits.
  5. 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.

Figure 4Drop a SIGKILL anywhere on one report's life and watch the restart.

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.

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:

Figure 5A session tree. Silence links, grow it, and let reports climb.

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

9The module map

One background subagent, end to end, and the same path after a crash.

Figure 6Who calls whom, and what each step writes.

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.

Figure 7Seven tasks, four waves.

Try: hover, focus or tap a task. Blue outlines mark the tasks it builds on.

Related pull requests

Decisions recorded