diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index de8fc1deae452..a9408c099814e 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -18,7 +18,9 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -192,6 +194,61 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { Layer.provideMerge(layerBase), ); + it.effect.each(["replacement", "removal"] as const)( + "hides retired instances during reconcile (%s)", + (scenario) => + Effect.gen(function* () { + let closed = 0; + const replacementStarted = yield* Deferred.make(); + const releaseReplacement = yield* Deferred.make(); + const driver: typeof CodexDriver = { + ...CodexDriver, + create: (input) => + Effect.gen(function* () { + if (input.displayName === "Second") { + yield* Deferred.succeed(replacementStarted, undefined); + yield* Deferred.await(releaseReplacement); + } + const instance = yield* CodexDriver.create(input); + yield* Effect.addFinalizer(() => Effect.sync(() => closed++)); + return instance; + }), + }; + const retiredId = ProviderInstanceId.make("retired"); + const siblingId = ProviderInstanceId.make("sibling"); + const nextId = scenario === "replacement" ? retiredId : ProviderInstanceId.make("new"); + const entry = { + driver: CodexDriver.driverKind, + enabled: false, + config: makeCodexConfig({}), + }; + const { registry, mutator } = yield* makeProviderInstanceRegistry({ + drivers: [driver], + configMap: { [retiredId]: entry, [siblingId]: entry }, + }); + const sibling = yield* registry.getInstance(siblingId); + const reconcile = yield* mutator + .reconcile({ + [siblingId]: entry, + [nextId]: { + ...entry, + displayName: "Second", + config: makeCodexConfig({ customModels: ["replacement-model"] }), + }, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(replacementStarted); + expect(closed).toBe(1); + expect(yield* registry.getInstance(retiredId)).toBeUndefined(); + expect(yield* registry.listInstances).toEqual([sibling]); + yield* Deferred.succeed(releaseReplacement, undefined); + yield* Fiber.join(reconcile); + expect((yield* registry.getInstance(nextId))?.displayName).toBe("Second"); + expect(yield* registry.getInstance(siblingId)).toBe(sibling); + expect(closed).toBe(1); + }).pipe(Effect.provide(layerTest)), + ); + it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => Effect.gen(function* () { const staleId = ProviderInstanceId.make("codex_stale"); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 3bbbe2900b9f2..02d769f5cfd45 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -179,7 +179,13 @@ const makeReconcile = (input: { replacedIds.add(instanceId); } } - for (const id of [...removedIds, ...replacedIds]) { + const retiredIds = [...removedIds, ...replacedIds]; + if (retiredIds.length > 0) { + const remaining = new Map(previousEntries); + for (const id of retiredIds) remaining.delete(id); + yield* Ref.set(state.entries, remaining); + } + for (const id of retiredIds) { const live = previousEntries.get(id); if (live) { yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore);