Repository navigation
Description
shell: plugin suffix rebuild drops background completion notifications
Summary
A background shell can finish successfully without notifying its Session after Plugin.activate reconstructs the suffix containing opencode.tool.shell. The Job survives and retains its completed output, but the completion observer belongs to the old plugin activation scope and is cancelled when that scope closes.
This report concerns that specific suffix-reconstruction path, not every reload. An unchanged-activation control delivers its notification and clears its background marker normally.
Environment
- OpenCode source package version:
2.0.32; in-process Core fixture, not an installed CLI/server reproduction. - Tested custom checkout:
72d0dce8ea437ea061e0680110fa19a81aa118fd. - Fixed official V2 comparison:
63c1a27af5a391d9caa55b82694f496911e1859b. - Runtime / OS: Bun
1.4.2(744846f84), Windows10.0.22631, win32 x64. - Terminal: not applicable; no TUI was involved.
- Shell execution: controlled process handle using the existing Core
ChildProcessSpawnerfixture pattern; no external shell was launched. - Plugins: fixture generations
[audit.prefix, opencode.tool.shell], then[opencode.tool.shell]; no user plugins or configuration were loaded. - Isolation: private HOME, ProgramData, ALLUSERSPROFILE, APPDATA, XDG, config and temporary directories; in-memory Config and SQLite; all fetch destinations blocked, with zero attempts recorded. No real provider, model request, user database, private content or credentials were used.
Reproduction
Build an offline Core fixture with the real Plugin, ShellTool, Tool, Shell, Job, Session and KV services. Reuse testEffect, tempLocationLayer, tempGlobalLayer, hostEnvironmentLayer and the Tool execution helper from packages/core/test. Use Config.testLayer, SessionExecution.noopLayer, offline models and a controlled spawner handle, as in shell-retention.test.ts.
-
Create a Session in the temporary Location. Register a no-op prefix plugin followed by the real ShellTool generation through
Plugin.activate. -
For the ShellTool generation, capture its required services without capturing Scope, as
PluginInternal.listdoes. Let actual plugin activation supply its child Scope. -
Execute the real tool snapshot with this input:
{ "command": "echo fixture", "background": true }Keep the controlled process blocked on a Deferred. Confirm the tool returned
status: running, the Job is running, the Session has no completion notice, andjobs.pendingBackgroundcontains one running marker. -
Await
plugins.activate([shellGeneration]), removing the preceding no-op generation. This reconstructs the shell plugin suffix while leaving the Shell service and Job alive. Instrumented activation counts become 2 starts / 1 closed scope. -
Release the process handle with output
fixture-output\nand exit code 0. Awaitjobs.wait({ id: shellID }), then inspect the Session inbox andjobs.pendingBackground. -
Control: use the same sequence but call
plugins.activate([prefixGeneration, shellGeneration])without changing either generation. Wait for the realJob.completeBackgroundacknowledgment before inspecting the final control state.
The rebuilt case closes the old activation before releasing the process. It does not depend on a timing race or an arbitrary sleep to infer that the observer is gone.
Expected Behavior
An already-backgrounded shell that finishes successfully should admit one completion notification and acknowledge its background marker, even when its tool plugin was reconstructed while the independently owned Job remained alive.
Actual Behavior
| Observation | Unchanged activation | Reconstructed suffix |
|---|---|---|
| ShellTool starts / closed scopes | 1 / 0 | 2 / 1 |
| Final Job status | completed | completed |
| Final Job output | fixture-output\n |
fixture-output\n |
| Session shell notifications | 1 | 0 |
pendingBackground |
empty | 1 completed marker containing the output |
The unchanged control passes. The reconstructed case fails the notification assertion: Expected length: 1; Received length: 0. The retained marker is evidence that the output is not lost; the broken behavior is automatic notification/acknowledgment.
Additional Context
Relevant relative source locations:
packages/core/src/tool/plugin/shell.ts:108,157-189,254-256: captures the plugin Scope and forksnotifyWhenDoneinto it.packages/core/src/plugin.ts:108-138: closes and reconstructs the changed activation suffix.packages/core/src/job.ts:198-239: the independent Job persists its terminal background result.services/www/src/docs/content/tools.mdx:122-125: background calls are documented to notify the Session when they finish.
The affected Shell, ShellTool, Plugin and Job files are identical between the tested custom HEAD and fixed official 63c1a27. A separate coordinating-audit comparison at upstream dev commit 60a5a6c found only three AI-file changes relative to that official snapshot; these affected sources remained identical. Neither the full official revision nor 60a5a6c was separately executed in this reproduction.
This is distinct from child-session reactivation discussed in #48826: the reproduced failure is cancellation of a shell completion observer during Plugin.activate suffix reconstruction. A preliminary, non-exhaustive duplicate check has not identified an exact match.
Observed once in one controlled run, including the unchanged control. No live configuration watcher, HTTP API, server restart or Location-eviction scenario was exercised. Restart compensation was not tested. The retained launcher was subsequently adjusted to propagate the already-recorded Bun exit code after cleanup; that launcher-only change was not rerun and the test assertions were not changed.
Suggested solution (not implemented)
Own the completion observer at a lifetime matching its Job, or reliably reclaim pending shell notifications after the relevant activation is reconstructed. Preserve generation-aware ownership, stable notification IDs, and admission-before-acknowledgment deduplication. This need not introduce a permanent global subscription or restart unrelated work.
Activity
opencode-agent commentedon Oct 18, 2026
Thanks for the detailed report. I was able to reproduce this on the latest
dev(60a5a6c). The relevant source files are unchanged since v2.0.32.The script below is a core test that follows your steps:
[audit.prefix (no-op), opencode.tool.shell]throughPlugin.activate. The shell plugin is given its services withoutScope, asPluginInternal.listdoes.{ "command": "sleep 1; printf fixture-output", "background": true }. It returnsrunning, with onependingBackgroundentry.[opencode.tool.shell], then wait for the job.Results:
fixture-output, but the session gets 0 notifications andpendingBackgroundkeeps the completed entry.pendingBackgroundis empty.This matches your analysis:
notifyWhenDoneis started in the plugin's activation scope (forkIn(scope)intool/plugin/shell.ts), and that scope is closed when the shell plugin is rebuilt.Related, but not the same problem:
Reproduction script (shell-suffix-repro.test.ts)
Run it:
bun shell-suffix-repro.test.tsreynold-hu commentedon Oct 18, 2026
I'm working on this and will open a PR. The plan matches the suggested direction: the ShellTool plugin reclaims pending shell backgrounds when it is activated, waits for still-running jobs and replays the notification from the persisted Job marker for finished ones, then acknowledges the marker. That keeps the observer tied to the current activation without moving notification ownership out of the shell tool.
reynold-hu commentedon Oct 18, 2026
Opened #56780 with the reclaim approach. The new repro test fails on the parent commit (notices: 0, pending: 1) and passes with the fix; the unchanged-activation control is covered in the same file.