fix(plugin): hot-reload plugins on harness reload when the plugin set changes

Merged
PI
3mo ago
+266-6

What

Extends the SIGHUP harness reload to plugins, closing the last hot-reload gap after #9 covered tools. With this, a plugins/ change on a live box is adopted by an in-place reload — no opencode respawn, no dropped sessions.

Two pieces:

  1. Plugin.reload() in the harness reload chain. Plugin state was the one harness cache the reload never touched — plugins loaded once per process, so a converge that shipped new plugin code needed restart-opencode. reload() fingerprints the freshly re-read config's plugin origins and invalidates the state only on a mismatch, which runs the old hooks' dispose finalizers and re-imports on next access.

  2. Realpath-before-import in PluginLoader.load. The same bug #9 fixed for tools: the Replo upgrader swaps the plugins dir behind a versioned symlink, and the module loader's cached symlink resolution would import new files from the old, deleted target (or serve the stale cached module). A fresh realpath both defeats the stale resolution and gives updated code a new module-cache key. Same review posture as #9: no fallback on resolution failure — the unresolved path is the bug.

Why fingerprint instead of always invalidating (the tools approach)

Tools are stateless definitions; re-importing them every reload is free. Plugin hooks are stateful — the braintrust/raindrop tracing plugins hold live per-session span state — and harness reloads fire on every env rotation (~4×/day per active box), not just on plugin changes. Blindly disposing and re-instantiating hooks on each rotation would break traces mid-session for zero benefit.

The fingerprint is the origin specs plus, for path plugins, a fresh realpath of the target. Because the harness ships plugins/ as a content-addressed versioned dir behind a symlink, the realpath is effectively a content hash: it changes exactly when plugin code changes and is stable across rotations. Fixed-path npm globals (braintrust, raindrop) never change realpath, so their in-place version bumps still adopt on the next natural respawn — same as today, and the same posture as the upgrader's npmInstall row.

Known limits (unchanged from today)

  • A plugin file edited in place (same realpath) is invisible to the fingerprint and to Bun's module cache — dev workflows that don't flip a versioned dir still need a restart. Production always flips.
  • Structural MCP config changes still need a restart (documented in harness-reload.ts).

Testing

  • New test/plugin/reload.test.ts: versioned-dir flip → reload serves v2 hooks and runs v1's dispose (verified the test fails without the loader realpath fix); no-change reload keeps hook identity (no re-instantiation); reload before first plugin load is a no-op.
  • harness-reload.test.ts now asserts plugin.reload runs once per instance, alongside the MCP header refresh.
  • bun test over test/plugin/, test/tool/registry.test.ts, test/server/, test/config/, test/cli/serve/serve-sighup.test.ts: all green (one pre-existing PTY-test timeout flake under full parallel load, passes in isolation on both dev and this branch). tsgo --noEmit clean.

Follow-up (andytown, not this PR)

Once the live-process fleet is on a release carrying this, move plugins (with tools, per REPL-29766) from RESTART_HARNESS_DIRS to RELOAD_HARNESS_DIRS in sandbox-upgrader/entries.ts. Until then the restart-on-converge stays, which is also what forces adoption of this binary.

🤖 Generated with Claude Code


Summary by cubic

Hot-reloads plugins on SIGHUP when the resolved plugin set changes, adopting new code without restarting and keeping live sessions intact. The reload is order- and options-aware and only runs when fingerprints differ; replaced hooks are disposed.

  • New Features

    • Added Plugin.reload() that fingerprints plugin origins (spec + options + fresh realpath for path specs), is order-sensitive, and invalidates only on change. Runs during harness reload after config invalidation, before tool registry invalidation, and before MCP header refresh.
  • Bug Fixes

    • PluginLoader.load now imports through a fresh realpath (including file:// handling) to defeat stale symlink resolution and ensure new module-cache keys when a versioned plugins/ dir flips.
    • Closed a reload race by running plugin.reload() before tools.invalidate(), preventing a concurrent request from caching a tool registry built against the old plugin set.