Repository navigation
Description
Summary
A plugin reload failed with failed to reload plugins. After that, sessions started auto-compacting even though the config had compaction.auto: false.
The failure is in the plugin generation swap: the registry closes the old generation before loading the new one. A cleanup finalizer that throws aborts the whole swap. Builtin plugins, including opencode.config.compaction, have already been closed and are not re-registered. SessionCompaction then rebuilds from its default { auto: true }, so the auto-compaction gate no longer skips. A later successful reload restores the builtin and the compactions stop.
Environment
- OpenCode v2.0.32 on macOS.
- Config contains
compaction: { auto: false }. - A user plugin whose cleanup throws a
ReferenceErrorafter a refactor (the cleanup references a binding that was removed from the setup scope).
Timeline
- T0: the plugin loads and works.
- T1: a refactor changes the plugin's cleanup; the plugin still loads (setup is fine), but the cleanup now references a binding that no longer exists.
- T2: a reload is triggered by a config/plugin change. The reload fails; the log shows
failed to reload pluginswith theReferenceError. - T3: sessions begin to auto-compact even though
compaction.auto: false; compaction records showreason: autoandreason: overflow. - T4: further reload attempts fail the same way.
- T5: after the trigger is removed (the cleanup no longer throws, or the plugin is removed), the next reload succeeds; the builtin is restored; no further auto compaction.
Root cause
- Activation order (
packages/core/src/plugin/supervisor.ts) isinternal.pre→ user plugins →internal.post.ConfigCompactionPluginis ininternal.post, i.e. last. Plugin.activate()(packages/core/src/plugin.ts) closes the old generation first, in reverse activation order, then loads the new generation. The close loop has no per-plugin catch; a throwing finalizer propagates and aborts the whole activation.load()isolates only the plugin effect: it wraps it inEffect.exit, logsfailed to load plugin, and can restore the previous activation. The failure here is in the close/unload path, outsideload, so that isolation does not apply. (The log showsfailed to reload plugins, notfailed to load plugin.)SessionCompactionstarts frominitial: () => ({ auto: true, keep: 15_000 })(packages/core/src/session/compaction.ts). The builtin config plugin's transform is what appliesauto: falsefrom the config. With the transform gone,state.get()rebuilds to the defaulttrue, and the gateif (trigger.reason !== "manual" && !settings.auto) return { status: "skipped" }no longer skips.- Because builtins are last in activation order, the reverse close loop disposes them first. By the time the user plugin's cleanup throws, the builtin has already been unloaded; the new generation never loads, so it is not restored.
Repro
The complete repro is inline below. It does not reference any local file, gist, or external
link. Add it as packages/core/test/plugin-generation-failure.test.ts in the OpenCode repo and
run:
cd packages/core && bun test test/plugin-generation-failure.test.ts
# 1 pass, 0 failimport { expect } from "bun:test"
import { Effect } from "effect"
import { Command } from "@opencode/core/command"
import { Plugin } from "@opencode/core/plugin"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
const broken = (revision: string) => ({
id: "broken",
revision,
effect: () =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.die(new ReferenceError("cleanup binding is not defined")))
}),
})
const builtin = (revision: string) => ({
id: "builtin",
revision,
effect: (ctx: any) =>
ctx.command.transform((editor: any) => {
editor.add({ name: "builtin", description: revision, execute: () => Effect.void })
}),
})
it.live("repro: a cleanup die during the generation swap drops the builtin transform", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.activate([broken("1"), builtin("1")] as any)
expect((yield* commands.get("builtin"))?.description).toBe("1")
const exit = yield* plugins.activate([broken("2"), builtin("2")] as any).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
expect(yield* commands.get("builtin")).toBeUndefined()
}),
)The test models the order: broken is the user plugin, builtin is last like internal.post. In generation 1 the builtin command exists. In generation 2 the reverse close loop disposes the builtin first; the broken plugin's finalizer dies; activate() fails; commands.get("builtin") is undefined. The test asserts the current buggy behavior, so it passes as a reproduction; after a fix, invert the assertion.
Possible fix
Minimal (recommended): catch and log each old slot's close failure so one plugin's cleanup cannot abort the swap:
yield* Effect.forEach(
Array.from(previous.entries()).toReversed(),
([id, slot]) =>
Effect.gen(function* () {
active.delete(id)
if (slot.activation && !slot.activation.failure)
yield* Scope.close(slot.activation.scope, Exit.void).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to close plugin", { "plugin.id": id, cause }),
),
)
}),
{ discard: true },
)This lets the new generation load and re-register builtins; the bad plugin's cleanup failure is logged and isolated.
Stronger options:
- Activate the new generation before closing the old, or keep the previous generation if activation fails (transactional swap). Tradeoff: plugins may hold process-wide resources, so coexistence needs staging.
- Separate builtins from user-plugin generations (or keep them in the unchanged prefix) so user plugin changes cannot unload them. Larger architectural change.
- On activation failure, surface the failed generation instead of silently running with defaults; at minimum log the failing plugin id.
Regression tests: invert the repro to assert the builtin survives after a throwing cleanup; add a case for a throwing finalizer in a user plugin.
Expected behavior
A user plugin failure should never silently reset builtin state or bypass configuration such as compaction.auto: false.
Activity
opencode-agent commentedon Oct 19, 2026
Thanks for the detailed report and the test. I confirmed this on v2.0.32 and it still happens on the latest
dev(f394d9d410bc).The script below is a two-case bun test that goes in
packages/core/test/:internal.post) that registers a command. Swapping to a new generation works and the builtin's new revision is registered.ReferenceError. The secondactivate()fails, and afterwards the builtin's command is gone. The old builtin was closed first (reverse order) and the new generation never loaded.This matches your analysis: the close loop in
Plugin.activate(packages/core/src/plugin.ts) has no per-plugin error handling. I tested the mechanism with a command transform and didn't run the fullcompaction.auto: falsepath end to end, butopencode.config.compactionis applied through the same kind of transform.Related: #49982 (a failed reload drops custom agents and commands) and #52618 (a failed reload wipes the provider registry).
Reproduction script (plugin-generation-failure.test.ts)
Run it:
bun plugin-generation-failure.test.tsJun516 commentedon Oct 19, 2026
I'd like to work on this if it is still available. I'll keep the fix focused on completing plugin-generation reloads after a cleanup throws, with regressions for builtin registrations and cleanup order. I checked #55754; its scheduling change does not cover this failure. I'll use Codex to prepare and test the patch.
fix(core): keep the generation swap when a plugin cleanup throws