diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 292ef66ff2ee..f76633011f88 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; @@ -20,6 +21,10 @@ import { TestClock } from "effect/testing"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +import { + createProviderVersionAdvisory, + makeCachedProviderMaintenanceResolution, +} from "./providerMaintenance.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -150,6 +155,56 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect( + "rereads installer metadata on explicit refresh while background checks stay cached", + () => + Effect.scoped( + Effect.gen(function* () { + const latestVersion = yield* Ref.make("1.0.0"); + const enriched = yield* Queue.unbounded(); + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + Ref.get(latestVersion).pipe( + Effect.map((version) => ({ ...maintenanceCapabilities, latestVersion: version })), + ), + ); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.succeed(refreshedSnapshot), + enrichSnapshot: ({ snapshot, publishSnapshot }) => + Effect.gen(function* () { + const capabilities = yield* resolveMaintenance(); + const next = { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + latestVersion: capabilities.latestVersion ?? null, + maintenanceCapabilities: capabilities, + }), + }; + yield* publishSnapshot(next); + yield* Queue.offer(enriched, next); + }), + refreshInterval: "1 minute", + }); + assert.strictEqual((yield* Queue.take(enriched)).versionAdvisory?.status, "current"); + + yield* Ref.set(latestVersion, "2.0.0"); + yield* TestClock.adjust("1 minute"); + assert.strictEqual((yield* Queue.take(enriched)).versionAdvisory?.latestVersion, "1.0.0"); + + yield* provider.refresh; + const updated = yield* Queue.take(enriched); + assert.strictEqual(updated.versionAdvisory?.latestVersion, "2.0.0"); + assert.strictEqual(updated.versionAdvisory?.status, "behind_latest"); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 119c577474c8..e25c68a161d6 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -286,7 +286,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return { resolveMaintenance: input.resolveMaintenance, getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), - refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), + // Explicit refreshes must observe installer changes such as `brew update` immediately. + // The interval loop keeps using refreshSnapshot directly so background probes stay cached. + refresh: Effect.gen(function* () { + yield* input.resolveMaintenance({ fresh: true }); + return yield* refreshSnapshot(); + }).pipe(Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, get streamChanges() { return Stream.fromPubSub(changesPubSub); diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 4d438a77b54e..5c000e120b14 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -14,7 +14,11 @@ import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "./ProviderDriver.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import { + createProviderVersionAdvisory, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; import * as Schema from "effect/Schema"; import { @@ -51,6 +55,89 @@ const provider: ServerProvider = { slashCommands: [], }; +describe("Homebrew compatibility recovery", () => { + const recoveryPolicy: ProviderCompatibilityPolicy = { + driver, + t3CodeRange: ">=0.0.42 <0.1.0", + recommendedRange: ">=0.156.0", + ranges: [ + { range: "<0.149.0", status: "broken" }, + { range: ">=0.149.0 <0.156.0", status: "unsupported" }, + { range: ">=0.156.0", status: "supported" }, + ], + }; + const snapshot = ( + currentVersion: string, + latestVersion: string | null, + updateCommand = "brew upgrade --cask codex", + ): ServerProvider => ({ + ...provider, + version: currentVersion, + versionAdvisory: createProviderVersionAdvisory({ + driver, + currentVersion, + latestVersion, + maintenanceCapabilities: makeProviderMaintenanceCapabilities({ + provider: driver, + packageName: "@openai/codex", + updateExecutable: "/opt/homebrew/bin/brew", + updateArgs: ["upgrade", "--cask", "codex"], + updateLockKey: "homebrew", + updateCommand, + }), + }), + }); + + it.each(["brew upgrade --cask codex", "brew upgrade codex"])( + "explains how to refresh stale metadata for %s and clears guidance after refresh", + (command) => { + const stale = snapshot("0.155.1", "0.155.1", command); + const result = applyProviderCompatibility(stale, [recoveryPolicy], []); + assert.strictEqual(result.versionAdvisory?.status, "current"); + assert.strictEqual(result.compatibilityAdvisory?.latestVersionStatus, "unsupported"); + assert.include(result.compatibilityAdvisory?.message ?? "", "Use >=0.156.0."); + assert.include(result.compatibilityAdvisory?.message ?? "", "brew update"); + assert.include(result.compatibilityAdvisory?.message ?? "", "this environment's host"); + assert.include(result.compatibilityAdvisory?.message ?? "", "refresh provider status"); + assert.strictEqual(result.message, stale.message); + assert.deepStrictEqual(result.versionAdvisory, stale.versionAdvisory); + + const refreshed = applyProviderCompatibility( + { ...result, versionAdvisory: snapshot("0.155.1", "0.157.1", command).versionAdvisory! }, + [recoveryPolicy], + [], + ); + assert.strictEqual(refreshed.compatibilityAdvisory?.latestVersionStatus, "supported"); + assert.notInclude(refreshed.compatibilityAdvisory?.message ?? "", "brew update"); + }, + ); + + it.each([ + ["0.148.0", "0.148.0"], + ["0.155.0", "0.155.1"], + ])("offers metadata recovery for incompatible %s with latest %s", (current, latest) => { + const result = applyProviderCompatibility(snapshot(current, latest), [recoveryPolicy], []); + assert.include(result.compatibilityAdvisory?.message ?? "", "brew update"); + }); + + it.each([ + ["0.156.0", "0.157.1", "brew upgrade --cask codex"], + ["0.155.1", "0.157.1", "brew upgrade --cask codex"], + ["0.155.1", null, "brew upgrade --cask codex"], + ["0.155.1", "0.155.1", "npm install -g @openai/codex@latest"], + ] as const)( + "does not add irrelevant recovery guidance for %s, %s, %s", + (current, latest, command) => { + const result = applyProviderCompatibility( + snapshot(current, latest, command), + [recoveryPolicy], + [], + ); + assert.notInclude(result.compatibilityAdvisory?.message ?? "", "brew update"); + }, + ); +}); + describe("provider compatibility", () => { it("bundles a compatibility policy for every built-in harness", () => { for (const builtIn of BUILT_IN_DRIVERS) { diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts index b4ce66f87603..6dd0703642c1 100644 --- a/apps/server/src/provider/providerCompatibility.ts +++ b/apps/server/src/provider/providerCompatibility.ts @@ -115,11 +115,22 @@ export function applyProviderCompatibility( ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) : undefined; + // Homebrew's resolver emits this canonical command only after verifying ownership. + // `brew info` can keep reporting an incompatible release until metadata is refreshed. + const needsHomebrewRefresh = + snapshot.versionAdvisory?.updateCommand?.startsWith("brew upgrade ") && + (advisory?.status === "unsupported" || advisory?.status === "broken") && + (latestAdvisory?.status === "unsupported" || latestAdvisory?.status === "broken"); return advisory ? { ...base, compatibilityAdvisory: { ...advisory, + ...(needsHomebrewRefresh + ? { + message: `${advisory.message} Homebrew's local package metadata may be stale. Run brew update on this environment's host, then refresh provider status to check for updates.`, + } + : {}), ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), }, }