From 34bfdda64b8fc541b3700bb1229a01cc4c03d290 Mon Sep 17 00:00:00 2001 From: AidenGeunGeun Date: Sat, 22 Aug 2026 08:04:11 +0900 Subject: [PATCH] fix(core): retain plugins across dist rebuilds --- packages/core/src/config/plugin/source.ts | 12 ++++++++-- packages/core/src/plugin/supervisor.ts | 11 ++++++++- packages/core/test/config/plugin.test.ts | 27 ++++++++++++++++++----- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/core/src/config/plugin/source.ts b/packages/core/src/config/plugin/source.ts index 0ea62434e927..41bdff6c7ba8 100644 --- a/packages/core/src/config/plugin/source.ts +++ b/packages/core/src/config/plugin/source.ts @@ -41,7 +41,8 @@ export const layer = Layer.effect( const watched = new Set() // Configured local plugin files can live outside config roots, where the - // config change feed cannot see them; watch those entrypoints directly. + // config change feed cannot see them. A generated dist directory needs a + // stable parent watch because replacing dist destroys a watch inside it. // Watches start on first sighting and are never torn down individually: // a stale watch after a config edit costs one deduped fs handle and a // no-op activation, and every watch dies with this layer's scope. @@ -58,8 +59,15 @@ export const layer = Layer.effect( // inside), so don't watch what can't trigger anything. if (yield* fs.isDir(operation.target)) continue watched.add(operation.target) - const updates = yield* watcher.subscribe({ path: operation.target, type: "file" }) + const directory = path.dirname(operation.target) + const generated = path.basename(directory) === "dist" + const updates = yield* watcher.subscribe( + generated + ? { path: path.dirname(directory), type: "directory" } + : { path: operation.target, type: "file" }, + ) yield* updates.pipe( + Stream.filter((update) => !generated || path.resolve(update.path) === operation.target), Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)), Effect.catchCause((cause) => Effect.logError("configured plugin watch failed", { target: operation.target, cause }), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 705cdf181060..01deb7d5aadc 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -40,6 +40,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( pre: readonly Plugin.Versioned[], post: readonly Plugin.Versioned[], operations: readonly ConfigPluginSource.Operation[], + previousPackages: ReadonlyMap, ) { const matches = (selector: string, target: string) => selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target) @@ -83,6 +84,11 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( error: plugin.error, tui: false, }) + const previous = previousPackages.get(operation.target) + if (previous) { + packages.set(operation.target, previous) + enabled.add(previous.id) + } continue } failures.delete(operation.target) @@ -99,6 +105,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( ...post.filter((plugin) => enabled.has(plugin.id)), ], failures: [...failures.values()], + packages, } }) @@ -138,6 +145,7 @@ export const layer = Layer.effect( const sources = yield* ConfigPluginSource.Service const bus = yield* Bus.Service const ready = yield* Latch.make() + let packages = new Map() let observed = 0 const activate = Effect.fn("PluginSupervisor.activate")(function* () { @@ -155,9 +163,10 @@ export const layer = Layer.effect( })) const operations = yield* sources.operations() // Apply config operations and load enabled package plugins into one ordered generation. - const resolved = yield* resolve(pre, post, operations) + const resolved = yield* resolve(pre, post, operations, packages) // Replace the active generation in one scoped, batched activation. yield* registry.activate(resolved.plugins, resolved.failures) + packages = resolved.packages }) const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe( // Make accepted work visible to flush before coalescing the burst. diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 34184bccd791..77a6fd416446 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -321,13 +321,13 @@ describe("PluginSupervisor config", () => { it.live("reloads a configured plugin when its source file changes", () => withLocation( - { plugins: ["-*", "./external/mutable.ts"] }, + { plugins: ["-*", "./external/dist/index.ts"] }, Effect.gen(function* () { yield* ready() const agents = yield* Agent.Service const bus = yield* Bus.Service const location = yield* Location.Service - const file = path.join(location.directory, "external", "mutable.ts") + const file = path.join(location.directory, "external", "dist", "index.ts") expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first") @@ -342,14 +342,31 @@ describe("PluginSupervisor config", () => { yield* Fiber.join(changed).pipe(Effect.timeout("5 seconds")) expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second") + + const removed = yield* bus + .subscribe(Plugin.Event.Updated) + .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true })) + yield* Effect.promise(() => fs.rm(path.dirname(file), { recursive: true })) + yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds")) + expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second") + + const recreated = yield* bus + .subscribe(Plugin.Event.Updated) + .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true })) + yield* Effect.promise(async () => { + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, mutablePlugin("third")) + }) + yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds")) + expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("third") }), false, async (directory) => { // Outside any {plugin,plugins} config-source directory, so only the // configured-entrypoint watch can observe the edit. - const external = path.join(directory, "external") - await fs.mkdir(external, { recursive: true }) - await fs.writeFile(path.join(external, "mutable.ts"), mutablePlugin("first")) + const dist = path.join(directory, "external", "dist") + await fs.mkdir(dist, { recursive: true }) + await fs.writeFile(path.join(dist, "index.ts"), mutablePlugin("first")) }, ), )