Skip to content

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

Open

Description

@Ploppy3

Summary

A V2 subagent with any pending background work ends its turn and is reported to its caller as completed while the work is still running. The parent is notified early with the subagent's premature reply.

This happens with every kind of background work a subagent can start in V2:

  • a background shell: shell with background: true
  • a background subagent (nested): subagent with background: true

When the work finishes, the subagent is woken and produces its real final answer, but that answer is never delivered to the parent. The result is stranded in the subagent session.

Nested variant of #45480 / #35066; contradicts the subagent tool's "You will be notified automatically when it finishes."

Image

Still present on opencode 2.2.6 (2026-11-07)

Re-verified on opencode 2.2.6 (latest release) with opencode-drive and a simulated model, once per kind of background work. Both fail the same way.

Background shell. Parent → child (subagent, background: true) → child runs shell with background: true (sleep 10):

+2.1s  parent launches the child in the background
+2.2s  child starts the background shell, ends its turn with EARLY_REPLY
+2.2s  parent woken with state="completed" + EARLY_REPLY (shell still running)
+12.2s shell completes; child woken, produces REAL_FINAL -> never delivered
+22.8s follow-up prompt to the parent: REAL_FINAL is not in its history

Background subagent (nested). Parent → child (subagent, background: true) → child launches a leaf subagent with background: true (the leaf runs sleep 10), experimental.subagent_depth: 5:

+2.2s  parent launches the child in the background
+2.3s  child launches the background leaf, ends its turn with EARLY_REPLY
+2.3s  parent woken with state="completed" + EARLY_REPLY (leaf still running)
+12.4s leaf finishes; child woken by <subagent state="completed">, produces REAL_FINAL -> never delivered
+22.8s follow-up prompt to the parent: REAL_FINAL is not in its history

Still present on opencode 2.0.21 (2026-10-01)

Re-verified live on opencode 2.0.21 with two independent runs (--standalone, isolated config, transcripts read back from the database). No experimental flag is needed anymore — OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS no longer exists in 2.0.21 and background subagents are core, yet the delivery failure reproduces unchanged:

  • Child ran a background shell (sleep 20; ...), ended its turn with EARLY_REPLY at +1s.
  • Shell completed at +21s; the child was woken by the <shell state="completed"> synthetic and produced REAL_FINAL at +22s.
  • The parent received exactly one subagent notification — state="completed" carrying EARLY_REPLY — and never received REAL_FINAL. The result remains stranded in the child session.

Second run, parent transcript (ses_f09a61904ffeKwQIrO799zLEVB):

07:25:00  child starts background shell, ends turn
07:25:01  child: EARLY_REPLY (idle)
07:25:21  child woken: <shell state="completed">; marker written
07:25:22  child: REAL_FINAL (idle) — stranded
07:25:46  parent receives: <subagent state="completed">EARLY_REPLY</subagent>
07:26:06  parent finishes; no REAL_FINAL notification ever arrives

Cause

The subagent's completion notification is produced when the child's job first settles (its first idle), so the parent is told completed with the premature reply. Nothing checks whether the child still owns running background work, whether a shell or a subagent. When that work later wakes the child and it produces its real final answer, that notification pathway has already been consumed and nothing delivers the real result. A fix was proposed in #49305 (closed unmerged); #57418 is the current open fix.

Environment

  • opencode 2.2.6 (current) — re-verified 2026-11-07; previously re-verified on 2.0.21, 2.0.11 and 2.0.5, originally observed on 2.0.8
  • experimental.subagent_depth: 5
  • Background subagents are core since 2.0.21; the OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS flag no longer exists

Reproduction

  1. Subagent A starts background work — shell with background: true, or subagent with background: true — then ends its turn.
  2. Parent is notified completed with A's early reply, while the work is still running.
  3. Work finishes; A is woken, collects it and produces its real final answer.
  4. Parent never receives that answer.

Live re-verification history

  • 2.2.6: both background shell and background subagent reproduce; see above.
  • 2.0.21: see above — same shape, two runs.
  • 2.0.11: parent notified completed at +6.5s with the premature reply; shell finished at +25.2s; subagent woken and produced REAL_FINAL; parent never received it.
  • 2.0.5: parent notified at +16s, 6s before the shell finished; child woken at +22s; real result never delivered.

Expected

Parent notified only once A is quiescent (no pending background shell or subagent left), or woken again with the real result.

Actual

Parent notified early with a premature reply; A's real result is never delivered.

Related: #45480, #35066, #36349, #48361, #45560, #50751

Activity

  1. changed the title [-][BUG] V2: background subagent result never collected — parent marked completed while child still running[/-] [+][BUG] V2: subagent with pending background work (task/shell, background: true) marked completed early — results never collected[/+] on Sep 13, 2026
  2. Ploppy3 commented on Sep 16, 2026

    @Ploppy3
    Author

    Fix opened in #49305 (targets v2).

  3. Ploppy3 commented on Sep 17, 2026

    @Ploppy3
    Author

    Still broken on v2.0.5 — reproduced live (Linux, background subagents enabled). Fix #49305 is unmerged (BLOCKED) and the binary lacks its symbols.

    Timeline (t=0 = spawn; child starts a sleep 20 background shell, then ends its turn early):

    • +3s — child idle succeeded, shell still running
    • +16s — parent gets the only notification: completed + early reply, 6s before the shell finished
    • +22s — shell completes; child is woken and produces its real result
    • parent never receives it — nothing pending, result stranded in the child session

    Nuance: the wake did reach the child here; the parent-side delivery failure is what reproduces. An isolated opencode run --standalone trial also showed the early notification, and there the pending shell never completed (likely killed at run exit) — possible extra severity variant.

  4. jlongster commented on Sep 23, 2026

    @jlongster
    Contributor

    @Ploppy3 thanks for explaining this, we are thinking through how to approach it. definitely aware of it. need to solve this for other use cases too where something needs to know if there's anything running in a session

    we will fix this, will take a little time to figure out the right APIs/states but we'll fix it!

  5. 0x4nur4g commented on Sep 25, 2026

    @0x4nur4g

    Corroborating with a fully-traced instance of the exact shape described.

    Worker with pending background shell processes: parent notified state="completed" at 14:57. The background shell finished at 15:22:07 — the child's transcript shows the wake source explicitly: a <shell id=… state="completed"> synthetic at 15:22:07, followed by a final verification turn and the real final report, idle at 15:26:23. Never delivered; the answer was stranded exactly as described. The parent then held its turn ~2.5 h (~200 no-op turns) waiting for a result that had already happened.

    Recovery today is only out-of-band: GET /api/session/{id}/message readback. Parent agents cannot self-detect this — the only ground truth is the HTTP API, and agent-side webfetch/Code-Mode fetch cannot authenticate to it (401 without CLI credentials), so there is no agent-facing way to ask "what is my child's actual state?"

    Population. Same local scan as the sibling reports: 44 instances of "terminal >60 s after last notification" over 09-10 → 09-25 (v2.0.16 back to beta-19425); losses concentrate on children with background shells or resumes; fresh-spawn first completions always delivered. Error-drain controls still notify, so it is not the drain-failure path.

    Can share the same sanitized form for this instance — event types, timestamps, opaque IDs, and the synthetic injection points only; no transcripts, paths, or project names.

  6. jlongster commented on Oct 1, 2026

    @jlongster
    Contributor

    @Ploppy3 just an update, we plan on changing it so that a subagent isn't "completed" and send a message back until all background tasks are complete. Still finalizing the api changes for it, it should land next week

  7. Ploppy3 commented on Oct 3, 2026

    @Ploppy3
    Author

    @jlongster perfect, thank you very much for you hard work.

  8. thymikee commented on Oct 17, 2026

    @thymikee

    This affects external orchestrators too, not just task subagents. We drive OpenCode 2.0.32 sessions from another app as delegated workers. When a worker starts a long background shell (a test gate), it often ends its turn with "waiting for the completion notification". The orchestrator sees that first idle and treats the worker as finished, even though OpenCode later wakes the session on <shell state="completed"> and the work continues.

    Of 23 suspicious turn ends we classified across two days, about 14 were this pattern. A session-level "background work pending" state, exposed to API clients alongside idle as discussed above, would let clients tell "done" from "waiting".

  9. Ploppy3 commented on Oct 19, 2026

    @Ploppy3
    Author

    @jlongster still not fixed, what is the current state of this?

  10. menyo-factorit commented on Oct 20, 2026

    @menyo-factorit

    Same pattern hits top-level sessions and plugins, not only subagents. On 2.1.0 I run OpenCode inside Orca. The agent started a long background shell (whisper on a ~1 h audio) and ended its turn. Orca's status integration marked the session done and sent a "finished" notification while the process kept running for several more minutes. The built-in opencode.notifications TUI plugin does the same: it says "Session done" on session.execution.succeeded, with no way to know background work is pending.

    +1 to the session-level "background work pending" state @thymikee asked for. Once you settle the shape, I'm happy to send a PR, e.g. expose running background jobs per session (from Job.runningBackground) so clients and the notifications plugin can wait before reporting done.

  11. Ploppy3 commented on Oct 21, 2026

    @Ploppy3
    Author

    Still reproducible on 2.1.2 (2026-10-21) — live, isolated --standalone instance, task subagent starting a background shell, transcripts read back from the database.

    12:45:23.253  child: EARLY_REPLY, ends turn
    12:45:24.660  child idle -> parent notified state="completed" with EARLY_REPLY
                  (shell still running; 13s before it exited)
    12:45:38.275  <shell state="completed"> synthetic wakes the child
    12:45:39.587  child: FINAL_REAL_RESULT_42  -> never delivered
    

    The parent's inbox had zero pending rows afterwards, and a second run (20s shell) showed the same shape. Source at tag v2.1.2 still has SubagentJob.start = sessions.resume(child) + read latest assistant message, with no background wait.

    #57418 looks like the fix. Same prompt, same 15s background shell, A/B on source:

    build what the parent got
    dev (base) state="completed" + EARLY_REPLY at +3s, shell still running
    #57418 branch nothing at first idle; notification only after the shell's completion notice is drained; tool returns FINAL_REAL_RESULT_42 at +21s

    Its new tool-subagent.test.ts case passes (12 pass / 0 fail in that file).

    One caveat for CI: the unit (linux) job on #57418 has a single failure (createStorage degrades gracefully when fs.watch throws), which looks unrelated to this change.

  12. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced this on 2.0.21, and it still happens on the latest dev (b94a305).

    Steps, using a simulated model:

    1. The parent starts a subagent with background: true.
    2. The subagent starts shell with background: true (sleep 10; …) and ends its turn with EARLY_REPLY.
    3. The parent is woken straight away by the "finished" notification, which carries EARLY_REPLY.
    4. About 10s later the shell finishes. The subagent is woken and replies REAL_FINAL.
    5. The parent never gets another notification. A follow-up prompt to the parent shows REAL_FINAL never reached its history.

    The recording and screenshot below show the run on dev, and the script below reproduces it.

    Related: #50751 (later turns of a resumed background subagent are never reported to the parent) and #45480 (umbrella issue on task lifecycle).

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#48826: a background subagent that ends its turn while its own
    // background shell is still running is reported to the parent as completed with its early
    // reply; its real final answer (produced after the shell finishes) never reaches the parent.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const parentRequests: string[] = []
    const log: string[] = []
    const t0 = Date.now()
    const note = (s: string) => log.push(`+${((Date.now() - t0) / 1000).toFixed(1)}s ${s}`)
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.serve((request) => {
            const body = JSON.stringify(request.body)
            if (body.includes("PARENT_TASK")) {
              parentRequests.push(body)
              if (body.includes("REAL_FINAL")) {
                note("parent request contains REAL_FINAL")
                return Stream.make(Llm.text("PARENT_SAW_REAL_FINAL"))
              }
              if (body.includes("FOLLOWUP_CHECK")) {
                note("parent follow-up request WITHOUT REAL_FINAL")
                return Stream.make(Llm.text("PARENT_NEVER_GOT_REAL_FINAL"))
              }
              if (body.includes("EARLY_REPLY")) {
                note("parent woken with EARLY_REPLY notification")
                return Stream.make(Llm.text("PARENT_SAW_EARLY_REPLY"))
              }
              if (body.includes("working in the background")) {
                note("parent: subagent launched in background")
                return Stream.make(Llm.text("PARENT_WAITING"))
              }
              note("parent: launching background subagent")
              return Stream.make(
                Llm.toolCall({
                  index: 0,
                  id: "call_subagent",
                  name: "subagent",
                  input: { agent: "general", description: "child job", prompt: "CHILD_TASK", background: true },
                }),
                Llm.finish("tool-calls"),
              )
            }
            // child session
            if (body.includes("CHILD_1DONE")) {
              note("child woken after shell completed -> REAL_FINAL")
              return Stream.make(Llm.text("REAL_FINAL"))
            }
            if (body.includes("call_shell")) {
              note("child ends turn with EARLY_REPLY while shell runs")
              return Stream.make(Llm.text("EARLY_REPLY"))
            }
            note("child: starting background shell")
            return Stream.make(
              Llm.toolCall({
                index: 0,
                id: "call_shell",
                name: "shell",
                input: { command: "sleep 10; echo CHILD_$((1))DONE", description: "slow work", background: true },
              }),
              Llm.finish("tool-calls"),
            )
          })
          yield* ui.submit("PARENT_TASK: delegate to a background subagent")
          yield* ui.waitFor("PARENT_SAW_EARLY_REPLY", { timeout: 60_000 }).pipe(Effect.catch(() => ui.screenshot("timeout")))
          // Give the child's shell time to finish and the child to write REAL_FINAL.
          yield* Effect.sleep(20_000)
          yield* ui.screenshot("after-wait")
          // Ask the parent again: its request shows whether REAL_FINAL ever reached it.
          yield* ui.submit("FOLLOWUP_CHECK: did the subagent send anything else?")
          yield* ui.waitFor(
            (frame: any) =>
              JSON.stringify(frame).includes("PARENT_NEVER_GOT_REAL_FINAL") ||
              JSON.stringify(frame).includes("PARENT_SAW_REAL_FINAL"),
            { timeout: 30_000 },
          ).pipe(Effect.catch(() => Effect.void))
          yield* Effect.sleep(1500)
          yield* ui.screenshot("final")
          const real = parentRequests.some((b) => b.includes("REAL_FINAL"))
          note(`RESULT: parent ever received REAL_FINAL = ${real}`)
          yield* Effect.log(log.join("\n"))
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  13. cevr commented on Nov 6, 2026

    @cevr

    I hit this too and have a fix on my fork, in case it helps the API design: cevr@80da76e (+86 src lines, with a live and a restart-recovery test).

    • Job tracks each backgrounded job's completion notice until its owner session admits it (undelivered(sessionID) / awaitDelivered). A removed session releases its notices.
    • The subagent job, live and restart-recovered, settles only once the child owns no undelivered notice and the turns those notices woke have gone idle (SessionBackground.awaitQuiet).

    So the parent gets one notification, carrying the child's real final answer. Same rule covers a child waiting on its own background subagent. Still reproduces on dev @ 885df74 without it. Happy to open a PR if this shape fits what you had in mind.

  14. Lolle2000la commented on Nov 6, 2026

    @Lolle2000la

    Nested subagent variant on 2.2.5

    The same failure reproduces when the child's pending background work is another subagent, not only a background shell/task. This is the shape a depth-2 orchestrator uses:

    1. lead calls subagent({ agent: "orchestrator", prompt: ... }) — foreground or background: true.
    2. The orchestrator calls subagent({ agent: "explore", background: true, prompt: ... }) and ends its turn. Its tool result is The subagent is working in the background (sessionID: ...).
    3. explore finishes; the orchestrator is woken by the <subagent state="completed"> synthetic and produces its real final report.
    4. lead only ever receives the orchestrator's first-turn "working in the background" text. The real report is stranded in the orchestrator session.

    Why it follows the same path

    • SubagentTool starts a job whose run is Session.resume(childSessionID), then returns the last completed assistant message.
    • Session.resume completes when the session's drain loop has no pending input. It does not wait for descendant jobs.
    • Job.block therefore returns finished at the child's first idle, and SubagentJob.notify (the synthetic completion back to the parent) waits on that same job, so it fires at the same point.
    • The later resumed run has no job or listener attached, so its output has nowhere to go.

    Version

    Reproduced consistently on 2.2.5 (Linux, experimental.subagent_depth: 2). The planned "do not complete until all background tasks are done" behavior is not in this build: #49305 is closed unmerged.

    Workaround for the nested variant

    Force the nested agent's own subagent calls to the foreground with an execute.before tool hook that rewrites event.input.background = false. Its turn then stays open until its children return, and the final report flows back through the normal tool result. Parallelism is preserved because the tool calls of a single assistant step execute concurrently.

    One related API gap surfaced while building a steering workaround: the V2 plugin client (ctx.session) does not expose session.background, even though the server endpoint exists and the TUI uses it. A plugin that needs to unblock a session stuck in a foreground tool call has to read the local service registration and call POST /api/session/{id}/background directly.

  15. changed the title [-][BUG] V2: subagent with pending background work (task/shell, background: true) marked completed early — results never collected[/-] [+][BUG] V2: subagent with pending background work (shell commands or subagents, background: true) marked completed early — results never collected[/+] on Nov 7, 2026
  16. nkoynov commented on Nov 8, 2026

    @nkoynov

    Same on 2.2.6, and the code is unchanged on dev ff4a652. SubagentJob (packages/core/src/session/subagent-job.ts) is only started by the subagent tool, command subagents and restart recovery, and its run is sessions.resume(child). When the child's own shell or nested subagent finishes, the notice wakes the child through sessions.synthetic → execution.wake with no job attached, so that turn's result has nowhere to go. SessionBackground.outstanding even notes that the child's shell wakes it again.

    We hit it on a real install. A background child ended its turn while its own shells and subagents were running, got woken six times over about an hour, and its final report never reached the parent, which then sat idle for ten hours.

    We've been running a fix on 2.2.6, checked in a VM with a repro like the ones above: the child starts sleep 45; exit 5 in the background, replies CHILD-STARTED and ends its turn; the parent gets that, and 45 s later it also gets CHILD-EXIT=5. SubagentJob watches session.execution.started, and when a subagent child's turn has no running job and was started only by synthetic notices, it starts a job that just joins that execution (awaitIdle). It never starts a turn itself, and the result goes through the normal report path. A call that steers the child in the meantime joins the same job, so there's still one report. One pitfall: Bus.subscribe only delivers events for the subscriber's Location, so the global node has to drop the location it was built in, or it misses other projects.

    This is a different direction from cevr's: it keeps the early reply and adds a report for each self-woken turn, instead of holding the first report back. Happy to open a PR against this issue if that works for you.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions