Skip to content

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

Open

Description

@Ploppy3

Summary

A V2 subagent with pending background work (task/shell with background: true) 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.

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 task tool's "You will be notified automatically when it finishes."

Image

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. When the pending background 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 exists in #49305 (still unmerged as of 2026-10-01).

Environment

  • opencode 2.0.21 (current) — re-verified live 2026-10-01; originally observed on 2.0.8, re-verified on 2.0.11 and 2.0.5
  • experimental.subagent_depth: 5
  • Background subagents are core in 2.0.21; the OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS flag no longer exists

Reproduction

  1. Subagent A starts background work (task or shell 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.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.
  • 2.0.21: see above — same shape, two runs.

Expected

Parent notified only once A is quiescent (no pending background work 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

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.

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