You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
V2 restart recovery drops the completion notice of an already-active subagent child #62792
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
Persist a running background-subagent recovery entry for a parent and child.
Start the child's turn in the recovering runtime and hold it at a barrier.
Run startup recovery while the child is active.
Release the child so it completes with an assistant answer.
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).
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:
Save a running background-subagent recovery entry for a parent and child.
Start the child's turn in the recovering runtime and hold it at a barrier until isActive(child) is true.
Run resumeSuspendedSessions while the child is still active.
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)PYtrap'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"
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).
Activity
opencodejr commentedon Nov 8, 2026
Thanks for the detailed report. I reproduced this on the latest
dev(eedb7fb3d);restart.tsthere is the same as atdc71d5f.I used a service-level test in
packages/core/test/session-execution.test.ts:runningbackground-subagent recovery entry for a parent and child.isActive(child)is true.resumeSuspendedSessionswhile the child is still active.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
returninrecoverSubagentthat you pointed out.The script below adds the test to a dev checkout and runs it.
Reproduction script (repro.sh)
Run it:
bash repro.shmradwankhalil commentedon Nov 9, 2026
We have a tested fix for this. When
recoverSubagentfinds 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.tsis unchanged ondev).Would you like a PR against
dev?