Visitar URL original
feat(core): subagents report to their parent every time they finish by kitlangton · Pull Request #61833 · anomalyco/opencode · GitHub
Skip to content

feat(core): subagents report to their parent every time they finish - #61833

Closed
kitlangton wants to merge 30 commits into
devfrom
pool-subagents
Closed

kitlangton wants to merge 30 commits into
devfrom
pool-subagents

Conversation

@kitlangton

@kitlangton kitlangton commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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:

  1. This one: core reports, the notify option, and holding a report until the child's background work is done.
  2. feat(core): run subagents in the background by default #62035: subagents run in the background by default (stacked on this one).
  3. feat(session): spawn subagent sessions and wait for their answer #62036: a spawn API and session.wait that returns the child's answer (stacked on feat(core): run subagents in the background by default #62035).

❌ Before

  1. The parent starts a subagent job.
  2. The job ends and the tool sends the parent one <subagent …> message.
  3. ❌ Anyone prompts the child again later: the parent gets nothing.
  4. ❌ A child that goes idle while its own background shell is still running reports too early, before the real result exists.

✅ After

  1. Any busy period of a child ends (succeeded, failed or cancelled).
  2. If the child still has background shells or subagents running, the report waits until they finish, then goes out once with the final state.
  3. Core sends the parent the same <subagent …> message, once for that busy period. A foreground call still gets the result inline, with no duplicate. notify: false on the child sends nothing, including after a restart.
- subagent tool: job finished -> notify parent (once, only for tool-started runs)
+ session core: child settled -> background work done? -> parent exists && notify != false && no one is blocking on it -> notify parent once for this busy period

📊 Proof

Case Before After
Child re-prompted after its job ended ❌ no report ✅ one report
Re-prompt overlaps the old job n/a ✅ one report per busy period
Child idle with a background shell running ❌ reports early ✅ reports after the shell exits
Silent child (notify: false) after a restart n/a ✅ stays silent
Server restarts mid-report n/a ✅ one report, not two
Server restarts while a report is held for background work n/a ✅ still reported once the work ends
Child re-prompted right after it reported n/a ✅ its own second report
Foreground call, child leaves a dev server running ✅ returns ✅ still returns; only a backgrounded job holds its report

159 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), and execution/restart.ts recovers them through the same settle path a normal launch uses. The hold-until-background-done logic is in subagent-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 pass
  • bun typecheck (packages/core): no type errors

Added by the opencode bot (AI-generated).

Fixes #48826

Related: #36349

Public API: adds optional notify input to the subagent tool and a subagent.notify session metadata key (backwards compatible)

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fixes #48826
Related: #36349

yield* terminal(event.value)
continue
}
if (!pending && !active) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] V2: subagent with pending background work (shell commands or subagents, background: true) marked completed early — results never collected

2 participants