Skip to content

feat(core): run shell commands in the process daemon - #61876

Open
jlongster wants to merge 29 commits into
devfrom
shell-daemon
Open

jlongster wants to merge 29 commits into
devfrom
shell-daemon

Conversation

@jlongster

@jlongster jlongster commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #

Type of change

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

What does this PR do?

Runs OpenCode's local shell commands in the opencode-pty daemon (protocol v8, daemon PR #61875) so they survive a planned server restart, and stops interrupts from throwing away a command's output.

Uses @opencode/pty 0.5.0 (release), built from #61875 and released in #62007. Protocol 8 is breaking: on upgrade, a running v7 daemon's handoff is discarded and persistent terminals start fresh once.

Transport (persistent-pty/):

  • Speaks protocol v8; removes the rename feature check, migrate() and other v7 compatibility.
  • Pipe-process API (createPipe, waitPipe, getPipe, signalPipe, setPipeMetadata, terminatePipe, listPipes). Errors are split: UnavailableError (request never sent, safe to fall back) vs RequestError (never re-run).
  • An incompatible daemon binary is detected once and not retried.

Shell commands:

  • Local commands (not Windows, not remote workspaces) run in the daemon, writing the same sh_X.out file. The server reads the file directly for the UI and the model; no output goes through the socket or server memory. Falls back to today's spawner only if the daemon can't be reached.
  • Shell.stop: SIGTERM, 3 s, SIGKILL, keeping the command and its file. Timeouts use it.
  • remove, the 25-command limit and the 7-day sweep terminate the daemon process before deleting its file.

Interrupt fix (applies to both paths):

  • Interrupting a shell call no longer deletes its output. The command is stopped, and the tool result has the output tail, the "full output saved to " notice and the shell ID.
  • Shutdown or a Location closing is not reported as a user interrupt.

Planned restart:

  • When a handoff has been prepared, the old server enters a "detaching" state before anything is torn down. Running foreground shell calls move themselves to the background (normal "moved to the background" result), and nothing signals or deletes daemon commands.
  • Job.settle no longer turns a teardown interrupt into "cancelled", so background shell jobs keep their row instead of sending "Cancelled" to the model.
  • The new server takes its commands back per Location, reattaches background jobs (including ones that finished during the restart), restores timeouts, and settles a call left running by a SIGKILL'd server by matching session/message/call IDs stored with the process.

Not handled: user-run !cmd commands aren't recovered after a restart.

How did you verify your code works?

bun run typecheck:tsc in core and server. Core tests (shell, shell tool, job, session runner/step, session execution/restart, location activity, persistent-pty, plus new shell-daemon and detaching tests): 476 pass with a v8 daemon binary, and the same suite passes without one (spawner fallback). Session tests were run with an isolated HOME. Server process, persistent-pty, job and message tests pass, including the persistent-pty smoke tests against the published 0.5.0 binary. Most shell tool tests run on the spawner, with a parity set on both paths; daemon tests use the in-memory fake daemon, plus one opt-in test against the real binary.

Checklist

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

Added by the opencode bot (AI-generated).

Fixes #39565

Related: #36348, #51828, #57977

Public API: adds optional killed and interrupted to shell tool result metadata, drops shellID from interrupted results; persistent-pty rename no longer returns PtyFeatureUnsupportedError (backwards compatible)

- Protocol 8: ProcessInfo with pty/pipe groups, create_pty, get, list { mode },
  required features; drop migrate(), the features fallback and the rename
  feature check (UnsupportedError).
- Keep daemon request errors (kind "request") apart from connect/startup
  failures; PersistentPty.get uses get { id } and maps unknown ids and pipe
  processes to NotFoundError.
- daemon.wait on its own connection with no timeout; interrupting closes it.
- Pipe process API for Shell: createPipe, waitPipe, getPipe, signalPipe,
  setPipeMetadata, terminatePipe, listPipes.
- Fake daemon speaks v8 including pipe requests.
@jlongster
jlongster marked this pull request as ready for review November 5, 2026 17:55

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Works as described for interrupts. On the base, interrupting a running shell call gave the model Tool execution interrupted with no output. With this change the model gets the output tail, the saved-file notice and the shell ID, the command runs in the 0.5.0 daemon and is stopped. Before/after screenshots below. Core and server tests and typecheck:tsc pass. I did not drive the planned-restart handoff end to end; only the fake-daemon tests cover it.

Public API: the shell tool result's metadata gains an optional interrupted field, and the persistent-pty rename route no longer returns PtyFeatureUnsupportedError (the error is still declared in the protocol, so nothing breaks for clients). The daemon protocol bump to v8 is internal. Backwards compatible.

Concerns (inline): what happens to daemon commands when a Location is evicted or reloaded without a server restart, and the timing-based sleeps in shell-daemon.test.ts.

Fixes #39565
Related: #36348, #51828, #57977

Before

After

Comment thread packages/core/src/shell.ts Outdated
// the daemon's commands back. Once closed, this one leaves their timers, stops and reporting to it, and only
// still answers its own waiters.
let closed = false
yield* lifecycle.awaitShutdown.pipe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could you check what happens when a Location shuts down without a server restart (idle eviction like #51828, or invalidate)? Once closed is set, stop() returns early (L556), and teardown only detaches daemon commands. So the background job's onInterrupt → shell.stop(id, "killed") does nothing. As far as I can tell, Job.settle then marks the job cancelled, because the registry isn't closed and we're not detaching. The process keeps running in the daemon, with no timeout.

The next boot of that Location takes it back in recover(), but only SessionRestart calls ShellTool.reattach, so no job watches it and the session never hears how it ended. The 7-day sweep also skips running processes. Either stop daemon commands on a Location close that isn't a handoff, or reattach their jobs when the replacement Location boots.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and fixed in 923b926. The scenario was reachable through a Location reload, vcs.init / debug.location.evict (plain invalidate), and narrowly through idle eviction. A closed Location now keeps watching the daemon commands it started (exit, timeout, stop), so the job finishes normally and the session is notified; only a server shutdown with a prepared handoff detaches. stop() works after close again, so jobs.cancel / cancelSession stop the command. The replacement still takes the commands back and is the only one that publishes events and applies retention; both read the deadline and stop reason from the daemon, so they can't disagree. notifyWhenDone no longer lives in the plugin's Location scope. New test in tool-shell: a background job survives a reload, cancelling one sends SIGTERM, the other completes and notifies.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fix in 923b926 covers the scenario I raised. I read the diff and ran the tests in packages/core. I didn't change anything in the checkout; the bun.lock modification was already there and I left it alone.

  • Finalizer (shell.ts ~L244): daemon commands are skipped unless detaching.isStopping() is true. A reload or eviction therefore leaves their wait and timeout fibers running. Those fibers come from runForkWith(context), not the Location scope, and they only need PersistentPty, which is a global node. The transport stays usable after the Location closes.
  • stop(): the closed early return is gone, so jobs.cancel / cancelSession on the old Location now send SIGTERM.
  • Two watchers: the old Location and its replacement both read stopReason and timeoutDeadline back through latest(), at timer expiry and on exit, so they report the same status. If both happen to stop the same command, it gets two SIGTERMs, which does no harm.
  • notifyWhenDone: forkDetach takes it out of the plugin's Location scope, so the session is still notified after the Location closes.
  • Tests: test/tool-shell.test.ts (63 pass, 23 skip, including the new reload test), plus shell, shell-daemon, detaching and job (37 pass, 1 skip). All of these ran on this machine's default path; I didn't run them against a real v8 daemon binary.

I don't see anything blocking.

Comment thread packages/core/test/shell-daemon.test.ts Outdated
expect(env.daemon.ops).not.toContain("terminate")
expect(env.signals(1)).toEqual([])
expect(env.signals(4)).toEqual(["SIGTERM"])
yield* Effect.sleep(150)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These tests use real-clock sleeps (Effect.sleep(20/50/100/150)) against 50–100 ms command timeouts to decide whether a timer did or didn't fire (also L219, L299–L326). On a loaded CI machine that will flake. Could they use TestClock (already imported), or wait on the fake daemon's signals/ops instead of fixed delays?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0d93fd7. No real-clock sleeps remain in shell-daemon.test.ts: timers run on TestClock, and fake-daemon socket I/O is awaited with an until(condition) helper. The restart test sets an expired deadline directly instead of sleeping past it. Also fixed an EPIPE in the fake daemon and a retention race that showed up under load; 11 runs under 2x CPU load pass. The real-daemon test now runs by default against @opencode/pty 0.5.0.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Checked against pr/head (0d93fd7). This resolves my concern.

  • shell-daemon.test.ts no longer has any Effect.sleep or bare Bun.sleep. Timeouts and the SIGTERM→SIGKILL grace period are driven by TestClock.adjust (L165–170, L285, L317, L346). Everything else waits on fake-daemon state through until().
  • until() (fixture/shell-daemon.ts:107) polls in real time on purpose, since the test clock can't drive socket replies. It gives up after 2,000 attempts of about 2 ms each, so a slow machine waits longer instead of failing.
  • The restart test sets timeoutDeadline: Date.now() - 1 (L243). That matches the code: shell.ts:745 restores timers from deadline - Date.now() on the wall clock, so an expired deadline fires at once with no clock involved.

One small thing: if process 3 is ever missing, if (expiring) at L243 skips the setup silently. shell.wait(before.expiring.id) would then hang until the test times out instead of failing with a clear message. Asserting it exists would be clearer, but it isn't a flake risk.

I didn't run the suite. The shared checkout's working tree is on an older commit with a modified bun.lock, so I didn't touch it, and I made no changes.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both earlier points are addressed. A Location that closes while the server keeps running now keeps watching its daemon commands. Timeouts and stops are read back from the daemon's metadata, and the background notifier no longer stops with the Location, so the session still hears how a command ended. The new tool-shell test covers this across a Location reload. The shell-daemon tests now use TestClock and the fake daemon's ops instead of real sleeps.

Re-checked at the new head: interrupting a shell call still gives the model the output tail, the saved-file notice and the shell ID. Core shell/job/session tests and typecheck:tsc pass. The public API impact is the same as in my earlier review. Nothing new to raise.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both earlier points are still addressed in this head. Daemon commands now live in a server-wide registry (Shell.daemonNode), so a Location closing no longer takes them down, and background-job notices run in the server's scope through daemon.watch. The shell-daemon tests use TestClock and the fake daemon's ops.

Re-checked with the simulated model: interrupting a running shell call gives the model the output, the saved-file notice and the shell ID (the base gives Tool execution interrupted with no content). The TUI now shows that result too; before/after screenshots below. Core and server tests and typecheck:tsc pass.

Public API: the shell tool's structured output gains an optional killed string, and its metadata gains killed: true when set. An interrupted result now has interrupted: true and no longer has shellID in its metadata; the text names the shell ID. The persistent-pty rename route no longer returns PtyFeatureUnsupportedError. All backwards compatible.

The latest commits also reformat generate.ts, schedule.ts and artifact.ts, which aren't part of this change; those hunks can be dropped.

Before

After

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: interrupted tools cannot retain partial results

1 participant