Skip to content

V2 restart recovery drops the completion notice of an already-active subagent child #62792

Open

Description

@mradwankhalil

Description

V2 restart recovery can drop the completion notice of a background subagent whose child turn is already running in the recovering runtime.

In recoverSubagent, a running recovery entry whose child is already active hits if (yield* execution.isActive(recovery.childSessionID)) return (dev dc71d5f, restart.ts#L191). Nothing observes that turn, so when it finishes the parent never receives a notice.

Expected: observe the existing turn and deliver exactly one notice with its real final answer/outcome, without re-prompting the child. A parent becoming idle should not be treated as success.

Plugins

None relevant; deterministic stub model, no provider calls

OpenCode version

dev dc71d5f (restart.ts blob 3bf4bae, unchanged since 664c8f0); originally observed at caf8d6a

Steps to reproduce

  1. Persist a running background-subagent recovery entry for a parent and child.
  2. Start the child's turn in the recovering runtime and hold it at a barrier.
  3. Run startup recovery while the child is active.
  4. Release the child so it completes with an assistant answer.
  5. Check the parent's inbox without running another recovery sweep.

Result with the current upstream restart.ts: 0 notices (test expected 1). With an observer-adoption change the same test delivers one notice with the child's answer and no replay.

How this was tested: a service-level test (adopts the active child's success outcome without replay, quiet=0) with the byte-exact upstream restart.ts (load hook verified SHA-256 61b4e584…bf30d) substituted into our local 2.2.6-based tree. The surrounding dependencies are from that tree, not a pristine dev build. Happy to adapt the test to a dev checkout if useful.

Possibly related but distinct: #48826 / #57410 (subagent reported complete while it still owns background work) and #58929 (live-owner exclusion during recovery; deferred entries also need a later recheck, which we did not report here because current dev lacks that guard).

Screenshot

N/A (headless backend; failing assertion quoted above)

Operating System

Windows 11 x64 (Linux/macOS not tested)

Terminal

N/A (headless Bun test run)

Tested upstream

dc71d5f

Activity

  1. opencodejr commented on Nov 8, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced this on the latest dev (eedb7fb3d); restart.ts there is the same as at dc71d5f.

    I used a service-level test in packages/core/test/session-execution.test.ts:

    1. Save a running background-subagent recovery entry for a parent and child.
    2. Start the child's turn in the recovering runtime and hold it at a barrier until isActive(child) is true.
    3. Run resumeSuspendedSessions while the child is still active.
    4. Release the child and wait for both sessions to go idle.

    The child ran exactly once, but the parent's inbox had 0 completion notices (expected 1), and the background entry was still pending. The existing test where recovery itself resumes the child still delivers its notice, so only the case where the child is already active fails. This matches the early return in recoverSubagent that you pointed out.

    The script below adds the test to a dev checkout and runs it.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #62792 on a dev checkout: inserts a service-level test into
    # packages/core/test/session-execution.test.ts and runs it.
    # Usage: REPO=/path/to/opencode bash repro.sh   (after `bun install` in REPO)
    set -euo pipefail
    REPO=${REPO:-/workspace/repo}
    cd "$REPO/packages/core"
    cat > /tmp/repro62792.ts <<'TS'
    
      it.effect("repro #62792: notifies the parent when recovery finds the child already active", () =>
        Effect.gen(function* () {
          const database = yield* Database.Service
          const jobs = yield* Job.Service
          const parent = Session.ID.make("ses_repro62792_parent")
          const child = Session.ID.make("ses_repro62792_child")
          yield* seedSessions(database, [parent])
          yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
          // Persist a running background-subagent recovery entry (as before a restart).
          yield* jobs.start({
            id: child,
            type: "subagent",
            recovery: {
              kind: "subagent",
              parentSessionID: parent,
              childSessionID: child,
              agent: "explore",
              description: "Inspect recovery",
            },
            run: Effect.never,
          })
          yield* jobs.background(child)
    
          const childRunning = yield* Deferred.make<void>()
          const release = yield* Deferred.make<void>()
          const drained: Session.ID[] = []
          const scope = yield* Scope.make()
          yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
          const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
          const context = yield* buildExecution(
            scope,
            ({ sessionID }) =>
              Effect.gen(function* () {
                drained.push(sessionID)
                if (sessionID === child) {
                  yield* Deferred.succeed(childRunning, undefined)
                  yield* Deferred.await(release)
                }
              }),
            undefined,
            restarted,
          )
          const restart = Context.get(context, SessionRestart.Service)
          const execution = Context.get(context, SessionExecution.Service)
    
          // The child's turn is already running in the recovering runtime, held at a barrier.
          yield* execution.resume(child).pipe(Effect.forkIn(scope))
          yield* Deferred.await(childRunning)
          expect(yield* execution.isActive(child)).toBe(true)
    
          // Startup recovery runs while the child is active.
          yield* restart.resumeSuspendedSessions
    
          // Child finishes.
          yield* Deferred.succeed(release, undefined)
          yield* execution.awaitIdle(child)
          for (let i = 0; i < 50; i++) yield* Effect.yieldNow
          yield* execution.awaitIdle(parent)
    
          const inbox = yield* SessionInbox.list(database.db, parent)
          const pending = yield* restarted.pendingBackground
          console.log("REPRO62792 child drains:", drained.filter((id) => id === child).length,
            "parent inbox notices:", inbox.length, "pending background entries:", pending.length)
          expect(drained.filter((id) => id === child)).toHaveLength(1)
          expect(inbox).toHaveLength(1)
        }),
      )
    TS
    python3 - <<'PY'
    p='test/session-execution.test.ts'
    s=open(p).read()
    anchor='  it.effect("delivers a subagent result persisted before restart without rerunning the child"'
    s=s.replace(anchor, open('/tmp/repro62792.ts').read()+"\n"+anchor,1)
    open(p,'w').write(s)
    PY
    trap 'git checkout -q test/session-execution.test.ts' EXIT
    # Expected: 1 parent inbox notice. Actual on dev: 0 notices, 1 pending background entry.
    bun test test/session-execution.test.ts -t "62792"

    Run it: bash repro.sh

  2. mradwankhalil commented on Nov 9, 2026

    @mradwankhalil
    ContributorAuthor

    We have a tested fix for this. When recoverSubagent finds the child already active, it now observes that running turn instead of returning early, so the parent receives exactly one completion notice with the child's answer when it settles, and the child is not replayed. It is covered by a service-level test equivalent to the reproduction above plus the existing recovery tests, on a 2.2.6-based build (restart.ts is unchanged on dev).

    Would you like a PR against dev?

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions