From 728053b6458305ac1b34ceeee50febb592195f95 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:41:13 +0530 Subject: [PATCH] fix(tui): avoid retrying broken plugin setup (#43441) Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com> --- packages/tui/src/plugin/context.tsx | 28 ++++++++++++++++++++ packages/tui/test/plugin-hot-reload.test.tsx | 12 +++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index ca3415372fd..f1ef9a388a3 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -239,6 +239,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d // Package resolution failures would otherwise retry a full npm install on // every watch event; remember them until the configuration changes. const npmFailures = new Map() + // A source that imports but fails setup must not tear down and restore its + // last-good generation again for every event in the same filesystem burst. + const setupFailures = new Map() const reconcile = async () => { await Promise.all(props.directories.map(watcher.wait)) const entries = [ @@ -303,6 +306,29 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d }) continue } + const setupFailure = setupFailures.get(target) + if ( + previous && + setupFailure?.version === resolved.version && + sameOptions(setupFailure.options, options) + ) { + failures.push({ + target, + id: previous.plugin.id, + status: "failed", + error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error, + }) + desired.set(previous.plugin.id, { + plugin: previous.plugin, + source: previous.source, + target, + version: previous.version, + options: previous.options, + enabled: previous.active, + }) + continue + } + setupFailures.delete(target) desired.set(resolved.plugin.id, { plugin: resolved.plugin, source: "external", @@ -377,6 +403,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d const error = await activate(id).then(() => undefined, errorMessage) if (!error) continue errors.set(id, error) + if (item.target) + setupFailures.set(item.target, { version: item.version, options: item.options, error }) if (!fallback) continue setStore("registrations", id, toRegistration(fallback)) if (!fallback.enabled) continue diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 0379aa173df..01e3e8c40f2 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -271,12 +271,17 @@ test("a save whose setup throws restores the previous version", async () => { const directory = path.join(tmp.path, ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) const marker = path.join(tmp.path, "a.txt") + const markerB = path.join(tmp.path, "b.txt") const source = path.join(directory, "a.ts") + const sourceB = path.join(directory, "b.ts") await writeFile(source, lifecycleSource(marker, "test.a", "a1")) + await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1")) await using app = await bootApp(tmp.path) const read = () => readFile(marker, "utf8") + const readB = () => readFile(markerB, "utf8") expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n") + expect(await until(readB, (value) => value === "b1:setup\n")).toBe("b1:setup\n") // The module imports fine but its setup throws — unlike an import failure, // the swap has already torn down a1, so keep-last-good means restoring it. @@ -295,6 +300,13 @@ export default { "a1:setup\na1:cleanup\na1:setup\n", ) + // A later reconcile must not retry the same broken generation. + await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2")) + expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe( + "b1:setup\nb1:cleanup\nb2:setup\n", + ) + expect(await read()).toBe("a1:setup\na1:cleanup\na1:setup\n") + // Fixing the file swaps out the restored version normally. await writeFile(source, lifecycleSource(marker, "test.a", "a2")) expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(