fix(tui): avoid retrying broken plugin setup (#43441)

Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-08-19 20:41:13 +05:30 committed by GitHub
parent bfe9917ee7
commit 728053b645
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 40 additions and 0 deletions

View file

@ -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<string, string>()
// 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<string, { version: string; options: Desired["options"]; error: string }>()
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

View file

@ -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(