Repository navigation
feat(core): subagents report to their parent every time they finish - #61833
kitlangton wants to merge 30 commits into
Conversation
A child Session with a parent now sends one `<subagent ...>` synthetic to its parent when its coordinator busy period settles, without the subagent tool observing it. - `SubagentReport` snapshots job state (blocked, get, recovery, remembered period) inside `settled` before the terminal publish, then maps the outcome to completed/error/cancelled and admits through a local inbox adapter that mirrors `Session.synthetic` (steer delivery, wake only a live, non-reverting parent). `execution.ts` gains `SessionInbox.node`; `Session.Service` stays out of the execution layer. - Reports are keyed to the job's notification ID when a backgrounded live job owns the period, so the SubagentJob observer and restart recovery reconcile the same admission instead of adding a second message. A jobless period gets a fresh ID; a period whose job finished or was cancelled mid-flight stays silent because its observer already reported it. - Suppressed for shutdown interruption (restart owns it), a missing child or parent, a silent child, a foreground block, a running job without a notification ID, and a period that finished without an assistant message. - `SubagentCompletion.deliver` now takes a structural synthetic adapter, so the settle report does not need `Session.Service`. The report never calls `completeBackground`: the durable marker must outlive admission. - `Job.Interface` gains a `recovery` reader; `Job.Info` deliberately keeps it out so snapshots cannot leak it. The replay test in `tool-subagent.test.ts` now asserts the marker exists at admission instead of being `completed`, because settlement admits while the job is still running; it replays a completed row to model the crash it covers.
Restart recovery already clears a silent child's durable marker without delivering a report. Pin that invariant: a suspended silent child resumed after a restart never reports or wakes its parent, and replaying a silent child's finished durable row admits nothing while still clearing the marker.
A silent child whose resume budget is exhausted must clear its durable marker without reporting an error to the parent.
Each busy period records where it began, on the clock that stamps event times, plus the child's newest answer at that moment. Settlement only reports an answer that completed during the period, excluding the starting answer by ID for millisecond ties and falling back to the timestamp alone when a committed revert deleted that row.
There was a problem hiding this comment.
The approach holds up: reporting from the settle hook in core, sharing the notification ID per busy period with the job observer and restart recovery, and keeping held reports on the session row. I read the diff and ran the 8 touched core test files (194 pass) and typecheck:tsc (clean). I did not run OpenCode end to end.
Public API: adds an optional notify input to the subagent tool (default true). It also writes a new subagent.notify key into child session metadata, which inherits to that child's own children. And it adds three nullable columns plus a partial index to session_v2 (additive migration). A user prompting a child session directly now also wakes the parent with a <subagent …> notice. All of this is backwards compatible: existing callers and sessions keep reporting by default.
Concern: a foreground subagent call now blocks until the child's background work finishes (see inline comment).
| yield* terminal(event.value) | ||
| continue | ||
| } | ||
| if (!pending && !active) return |
There was a problem hiding this comment.
run is now settle, so a foreground subagent call (jobs.block in the tool) only returns once SessionBackground.unsettled is false. Before, it returned when the child's busy period ended. If a child leaves a long-running background shell going (a dev server, a --watch), the parent's tool call now hangs until someone cancels or backgrounds it. Holding the report for that work makes sense, but for a foreground caller it might be better to return the inline result when the child goes idle and let the later settle report the final state. Or is waiting forever here intended?
There was a problem hiding this comment.
The new commit fixes the foreground hang: settle only waits for background work once the job has been moved to the background (via jobs.isBackgrounded), so a foreground call returns when the child stops. The two added tests cover both paths and are clear. I ran the touched core tests (196 pass) and typecheck:tsc (no errors). The public API impact is unchanged from my previous review. I have no new concerns.
A subagent now tells its parent every time it finishes, including when someone prompts it again later, and waits for its own background work first.
Today only the subagent tool reports back, once, when the job it started ends. If you open a child session and type into it, or the parent re-prompts it, the parent never hears about the result.
This is the first of three stacked PRs:
notifyoption, and holding a report until the child's background work is done.session.waitthat returns the child's answer (stacked on feat(core): run subagents in the background by default #62035).❌ Before
<subagent …>message.✅ After
<subagent …>message, once for that busy period. A foreground call still gets the result inline, with no duplicate.notify: falseon the child sends nothing, including after a restart.📊 Proof
notify: false) after a restart159 tests pass across the seven touched test files.
🗄️ Migration: adds three nullable columns to
session_v2(held_report_id,held_report_at,held_report_answer) and a partial index, so a held report survives a restart. Additive only.👀 Read:
packages/core/src/session/execution/subagent-report.ts. The job observer, restart recovery and the settle path can all try to report the same busy period; each period is tied to its job's start generation, and they share one notification ID, so the first one wins. Held reports are stored on the child's row (session/store.ts), andexecution/restart.tsrecovers them through the same settle path a normal launch uses. The hold-until-background-done logic is insubagent-job.ts; it applies only once a job is backgrounded, so a foreground call still returns when the child stops. That file also handles releasing the nearest waiting ancestor through a chain of silent children. The rest is routine.From a team design discussion · no user reports · prior art in #60360, #60618 and #57132 · reviewed before push
Checks run
bun test test/subagent-lifetime.test.ts test/subagent-report.test.ts test/session-store.test.ts test/job.test.ts test/session-execution.test.ts test/tool-subagent.test.ts test/database-migration.test.ts(packages/core): 159 passbun typecheck(packages/core): no type errorsAdded by the opencode bot (AI-generated).
Fixes #48826
Related: #36349
Public API: adds optional
notifyinput to the subagent tool and asubagent.notifysession metadata key (backwards compatible)