Skip to content

fix(core): reclaim background shell notifications after plugin rebuild - #56780

Open
reynold-hu wants to merge 2 commits into
anomalyco:devfrom
reynold-hu:shell-background-reclaim
Open

reynold-hu wants to merge 2 commits into
anomalyco:devfrom
reynold-hu:shell-background-reclaim

Conversation

@reynold-hu

@reynold-hu reynold-hu commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #56729

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

ShellTool forks its completion observer into the plugin activation scope. When Plugin.activate rebuilds the shell plugin suffix, that scope closes and the observer is interrupted, so a background shell that finishes afterwards never notifies its Session: the Job and its persisted background marker survive, but the Session receives nothing and the marker is never acknowledged.

The shell plugin now reclaims persisted shell backgrounds when it is activated:

  • For each marker it waits on the Job (immediately available when the Job already finished).
  • It replays the same notification as the normal observer, using the Job's recorded output/error for completed jobs.
  • It acknowledges the marker afterwards.

Jobs from a previous process are unknown to this process, so they are left for the existing restart recovery path. Delivery stays deduplicated by the persisted notification ID, so a reclaim that races a live observer still produces one notification, and completeBackground is idempotent. The observer itself stays owned by the shell tool; this only covers the lifetime gap between activations.

How did you verify your code works?

  • New packages/core/test/shell-plugin-rebuild.test.ts follows the reproduction in the issue: activate [audit.prefix, opencode.tool.shell], start a background shell, re-activate with only the shell plugin, then release the process. The rebuilt case now admits one notification and clears the marker; the unchanged-activation control still passes. On the parent commit the rebuilt case fails with notices: 0 and pending: 1.
  • bun test test/tool-shell.test.ts test/job.test.ts test/session-execution.test.ts test/shell-plugin-rebuild.test.ts: 97 pass, 63 skip, 0 fail.
  • packages/core typecheck passes; test/config/shell.test.ts and the shell-scan suites (328 tests) pass.

Screenshots / recordings

No UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #51828, #50751

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the reported case. I ran the #56729 reproduction (the PR's test) on the base branch and with the PR. On the base, the rebuild case gets no notification and leaves the background marker behind. With the PR, exactly one notification arrives and the marker is cleared. tool-shell, job, session-execution and the new test pass, and packages/core typecheck is clean.

Public API: unchanged.

Concerns:

  • Every location reclaims every location's backgrounds. Job is a global node and pendingBackground scans all markers, but the shell plugin is activated once per location. So whenever any location activates the shell plugin, including its first activation, it starts a waiter on every running background shell in the process, even ones whose own observer is still alive. I confirmed this with two locations: B's reclaim picked up A's running shell. Delivery is still deduplicated by notification ID, but the reclaim waiter and the live observer race on the same job. If the reclaim wins, the session gets a notification without the shell output metadata (exit code, truncation, ...), because reclaimBackground never passes output to ShellResult.notification. See the inline comments.
  • Approach. This copies the notification logic a third time (notifyWhenDone, recoverShell in session/execution/restart.ts, and now reclaimBackground). Running notifyWhenDone in a scope that lives as long as the Job/Shell service, rather than the plugin activation scope, would fix the bug where it starts and needs no reclaim pass. If you keep the reclaim, limit it to markers whose observer was actually interrupted, at least to the current location's sessions.
  • The test is the bot's reproduction pasted almost verbatim, about 160 lines of copied setup, a fixed sleep and debug output. See the inline comment.

Related: #51828, #50751

Comment thread packages/core/src/tool/plugin/shell.ts Outdated
// A rebuilt activation closes the scope that owned the previous notification observers. Reclaim
// persisted backgrounds owned by this process so their completion still reaches the Session.
// Jobs from a previous process are unknown here and stay for restart recovery.
yield* reclaimBackgrounds(jobs, sessions).pipe(Effect.forkIn(scope, { startImmediately: true }))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs on every activation of the shell plugin in every location, including the first one. Since Job is global, pendingBackground returns markers from all locations. A newly opened location B will therefore start waiting on location A's running background shells, whose observer was never interrupted, and race it to deliver the notification. I reproduced the extra waiter with two locations. Running notifyWhenDone in a longer-lived scope (Job/Shell service or the location layer) instead of the activation scope would avoid the reclaim and the race entirely. Otherwise, could the reclaim be limited to markers whose observer is known to be gone?

jobID: background.id,
shellID: recovery.shellID,
command: recovery.command,
state: info.status,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike notifyWhenDone, this doesn't pass output, so the notification has no ShellResult.metadata (exit code, truncation, etc.). The inbox keeps the first admission for an ID, so when this path wins the race against a live observer, the session's notification loses that metadata, and nothing reports it. This logic also duplicates notifyWhenDone and recoverShell in session/execution/restart.ts. A shared helper, or not needing the reclaim at all, would keep them consistent.

yield* plugins.activate(rebuild ? [shellGen] : [prefix, shellGen])

const result = yield* jobs.wait({ id: shellID })
yield* Effect.promise(() => Bun.sleep(1500))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is the reproduction script copied nearly verbatim: about 160 lines of setup from tool-shell.test.ts, the // Repro for header, console.log of the state, as any casts and a fixed Bun.sleep(1500) (timing-sensitive on slow CI). Could it be cut down to one focused case in tool-shell.test.ts, reusing its existing layers and withSession? Rebuild while a background shell runs, then wait for the notification event (e.g. take the first matching InboxEnqueued) instead of sleeping, and drop the debug output.

@reynold-hu

Copy link
Copy Markdown
Contributor Author

Addressed the review in 27ce8ed:

  • Reclaim is now limited to markers whose shell session lives in the current location (sessions.get(recovery.sessionID) and compare directory/workspace). A newly opened location no longer waits on another location's running shells, and within one location the old observer's scope is closed before the new activation loads, so the reclaim does not race a live observer.
  • The notification metadata remains absent in this path: the persisted marker only stores output/error, as in the restart recovery path. With the location filter + activation ordering, the reclaim no longer wins against a live observer.
  • Test cleanup: removed the console.log and the fixed 1500 ms sleep; it now waits for the notification (25 ms poll capped at 2 s). ool-shell + job + session-execution + the rebuild test: 97 pass / 63 skip; typecheck clean.

@reynold-hu
reynold-hu force-pushed the shell-background-reclaim branch from 27ce8ed to 80d6122 Compare October 21, 2026 14:41

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

shell: plugin suffix rebuild drops background completion notifications

1 participant