From d381546b113b01bae1fd781e252ba9aca08257f1 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 18 Jul 2026 01:51:40 +0800 Subject: [PATCH 1/2] fix(server): stop restoring stale OpenCode models (PR #4095) Treat successful OpenCode inventories, logout, disablement, and confirmed CLI removal as authoritative while preserving the last known-good models during startup and failed or partial probes. Add focused merge and cache-persistence coverage for removal, pending, failure, uninstall, and reconnect behavior. Co-authored-by: codex --- .../provider/Layers/ProviderRegistry.test.ts | 308 ++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 28 +- 2 files changed, 333 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 159d853121c..5efbb6f1c14 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops stale OpenCode models missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains stale OpenCode models when a refresh fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode provider status has not been checked in this session yet.", + } satisfies ServerProvider; + const loggedOutProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "OpenCode is available, but it did not report any connected upstream providers.", + } satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:03:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const authoritativeProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:04:00.000Z", + models: [previousProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:05:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ + ...previousProvider.models, + ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).models, + [], + ); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); + + const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); + const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); + + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -866,6 +1036,144 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect( + "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", + () => + Effect.gen(function* () { + const openCodeDriver = ProviderDriverKind.make("opencode"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const initialProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const authoritativeProvider = { + ...initialProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [initialProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(authoritativeProvider), + streamChanges: Stream.fromPubSub(changes), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-opencode-authoritative-persist-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: openCodeInstanceId, + }); + + yield* PubSub.publish(changes, authoritativeProvider); + + let cachedProvider = yield* readProviderStatusCache(filePath); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + + yield* PubSub.publish(changes, failedProvider); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + authoritativeProvider.models[0]!, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("returns the cached provider list when a manual refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..760c8e1c59e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,11 +78,31 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { + if (provider.driver !== ProviderDriverKind.make("opencode")) { + return true; + } + + // OpenCode's initial snapshot is deliberately non-authoritative while its + // first probe is still running. A probe error from an installed CLI/server + // is likewise partial: it could not establish the current inventory. + // Conversely, disabled and missing-CLI snapshots are authoritative removals, + // as are successful ready/warning inventories (including an empty one after + // logout or plugin removal). + const isPendingInitialProbe = + provider.enabled && !provider.installed && provider.status === "warning"; + const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; + return isPendingInitialProbe || didInstalledProviderProbeFail; +}; + const mergeProviderModels = ( + provider: ServerProvider, previousModels: ReadonlyArray, nextModels: ReadonlyArray, ): ReadonlyArray => { - if (nextModels.length === 0 && previousModels.length > 0) { + const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); + + if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) { return previousModels; } @@ -98,7 +118,9 @@ const mergeProviderModels = ( }; }); const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; + return shouldRetainMissingModels + ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))] + : mergedModels; }; export const mergeProviderSnapshot = ( @@ -109,7 +131,7 @@ export const mergeProviderSnapshot = ( ? nextProvider : { ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), + models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), }; export const mergeProviderSnapshots = ( From 4892befccc1d9152671cf5fcfbe07b96010064fe Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Tue, 21 Jul 2026 14:40:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(server):=20=E4=BF=9D=E7=95=99=20OpenCod?= =?UTF-8?q?e=20=E6=8E=A2=E6=B5=8B=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模型清单命令重试失败后返回探测错误,避免伪装成权威空清单。\n- Agent 元数据查询仍允许降级,不影响已成功获取的模型清单。\n- 更新本地探测回归测试,并通过目标测试、vp check 与类型检查。 --- .../provider/Layers/OpenCodeProvider.test.ts | 19 +++++++--- apps/server/src/provider/opencodeRuntime.ts | 38 +++++++++++++------ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 05160517bfe..41454b48b31 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -97,10 +97,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), loadInventoryFromCli: () => runtimeMock.state.inventoryError - ? Effect.succeed({ - providerList: { all: [], default: {}, connected: [] as string[] }, - agents: [], - } as OpenCodeInventory) + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; @@ -212,14 +215,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("degrades gracefully on CLI failure for local installs", () => + it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: opencode models failed", + ); }), ); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index b853662b037..63fcea22d19 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -690,22 +690,36 @@ const makeOpenCodeRuntime = Effect.gen(function* () { agentsResult = a2; } - // Degrade gracefully on failure — return empty inventory (warning status, not error) - let connected: string[] = []; - let allProviders: ProviderListResponse["all"] = []; - if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - connected = [...parsed.connected]; - allProviders = [...parsed.providers.values()].map((p) => ({ - id: p.id, - name: p.name, + if (modelsResult._tag === "Failure") { + const cause = Cause.squash(modelsResult.cause); + return yield* ensureRuntimeError( + "loadInventoryFromCli", + `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ); + } + if (modelsResult.value.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + }); + } + + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const connected = [...parsed.connected]; + const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( + (provider) => ({ + id: provider.id, + name: provider.name, source: "config" as const, env: [], options: {}, - models: p.models, - })); - } + models: provider.models, + }), + ); + // Agent metadata enriches model capabilities but is not required for an + // authoritative model inventory, so it may still degrade to an empty list. let agents: ReadonlyArray = []; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { agents = parseAgentListCliOutput(agentsResult.value.stdout);