diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 943928c4e18cf..1c2593ff7cce7 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -153,6 +153,9 @@ function layerMockSpawner( function makeRegistry( initialProviders: ServerProvider | ReadonlyArray = baseProvider, + options?: { + readonly installedVersionAfterUpdate?: string; + }, ) { return Effect.gen(function* () { const providersRef = yield* Ref.make>( @@ -193,7 +196,18 @@ function makeRegistry( refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), reloadSecretsAndRefresh: () => Ref.get(providersRef), - refreshAfterUpdate: () => Ref.get(providersRef), + refreshAfterUpdate: () => { + const version = options?.installedVersionAfterUpdate; + if (version === undefined) { + return Ref.get(providersRef); + } + return Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ + ...provider, + version, + })), + ); + }, refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), @@ -220,6 +234,7 @@ const makeTestRunner = ( ranges: [], })), }, + latestVersions: Layer.Layer = ProviderLatestVersions.layer, ) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( @@ -233,13 +248,28 @@ const makeTestRunner = ( forceRefresh: Effect.succeed(manifest), refreshInBackground: Effect.void, }), - ProviderLatestVersions.layer, + latestVersions, ), ), ), ), ); +function layerFailingLatestLookupAfterFirst() { + let lookups = 0; + return Layer.succeed(ProviderLatestVersions.ProviderLatestVersions, { + cached: (_key, lookup) => + Effect.gen(function* () { + lookups += 1; + if (lookups > 1) { + return yield* Effect.die("latest lookup failed"); + } + return yield* lookup; + }), + invalidate: () => Effect.void, + }); +} + describe("providerMaintenanceRunner", () => { it.effect("runs the allowlisted provider update command and records success", () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; @@ -269,7 +299,190 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect.each([ + { name: "current", probe: {}, skip: true }, + { name: "failed", probe: { status: "error" }, skip: false }, + { name: "behind", probe: { version: "2.1.309" }, skip: false }, + { name: "unknown", probe: { version: null }, skip: false }, + { + name: "installed current", + probe: { version: "2.1.309", installedVersion: "2.1.310" }, + skip: true, + }, + { name: "installed behind", probe: { installedVersion: "2.1.309" }, skip: false }, + { name: "installed unknown", probe: { installedVersion: null }, skip: false }, + ] as const)("only skips a successful fresh current probe: $name", ({ probe, skip }) => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + const refreshedInstanceIds: Array = []; + return Effect.gen(function* () { + const { registry, updateStatesRef, providersRef } = yield* makeRegistry({ + ...baseProvider, + version: "2.1.309", + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.1.309", + latestVersion: "2.1.310", + updateCommand: "claude update", + canUpdate: true, + checkedAt: "2026-04-10T00:00:00.000Z", + message: "Update available.", + }, + }); + const updater = yield* makeTestRunner({ + ...registry, + refreshInstance: (instanceId) => { + refreshedInstanceIds.push(instanceId); + return Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => + provider.instanceId === instanceId + ? { + ...provider, + version: "2.1.310", + ...probe, + checkedAt: "2026-04-10T00:00:01.000Z", + } + : provider, + ), + ); + }, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@anthropic-ai/claude-code", + updateExecutable: "claude", + updateArgs: ["update"], + updateLockKey: "claudeAgent-native", + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(refreshedInstanceIds, [CODEX_INSTANCE_ID]); + assert.deepStrictEqual(calls, skip ? [] : [{ command: "claude", args: ["update"] }]); + if (!skip) return; + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.strictEqual(result.providers[0]?.updateState?.message, "Already up to date."); + assert.deepStrictEqual( + (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]), + [ + ["queued", "Waiting for another provider update to finish."], + ["running", "Checking for the latest version"], + ["succeeded", "Already up to date."], + ], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("2.1.310"), + layerMockSpawner((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("still runs the installer when refreshInstance returns the cached snapshot", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + version: "2.1.310", + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@anthropic-ai/claude-code", + updateExecutable: "claude", + updateArgs: ["update"], + updateLockKey: "claudeAgent-native", + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [{ command: "claude", args: ["update"] }]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("2.1.310"), + layerMockSpawner((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("still runs the installer when precheck advisory enrichment fails", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ + ...baseProvider, + version: "2.1.310", + versionAdvisory: { + status: "current", + currentVersion: "2.1.310", + latestVersion: "2.1.310", + updateCommand: "claude update", + canUpdate: true, + checkedAt: "2026-04-10T00:00:00.000Z", + message: null, + }, + }); + const updater = yield* makeTestRunner( + { + ...registry, + refreshInstance: (instanceId) => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => + provider.instanceId === instanceId + ? { ...provider, checkedAt: "2026-04-10T00:00:01.000Z" } + : provider, + ), + ), + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@anthropic-ai/claude-code", + updateExecutable: "claude", + updateArgs: ["update"], + updateLockKey: "claudeAgent-native", + }), + ), + }, + undefined, + layerFailingLatestLookupAfterFirst(), + ); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [{ command: "claude", args: ["update"] }]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("2.1.310"), layerMockSpawner((command, args) => { calls.push({ command, args }); return { stdout: "updated" }; @@ -298,7 +511,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ stdout: "updated" })), ), ), @@ -331,7 +544,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner((_command, _args, options) => { seen.push(options.env); return { stdout: "updated" }; @@ -371,7 +584,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner((command, args) => { calls.push({ command, args }); return { stdout: "updated" }; @@ -433,7 +646,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner((command) => { calls.push(command); return { stdout: "updated" }; @@ -461,7 +674,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ stdout: "checking install\n", stderr: "Error: Could not detect the Codex installation method.\n\n", @@ -487,7 +700,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ code: 1 })), ), ), @@ -552,7 +765,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => { startedLatch.resolve(); return { @@ -571,7 +784,9 @@ describe("providerMaintenanceRunner", () => { "releases the running-provider marker when interrupted after queuing but before the lock run starts", () => Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); + const { registry } = yield* makeRegistry(baseProvider, { + installedVersionAfterUpdate: "1.0.0", + }); let blockQueuedState = true; const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} }; const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} }; @@ -612,7 +827,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), layerMockSpawner(() => ({ stdout: "updated" })), ), ), @@ -626,7 +841,9 @@ describe("providerMaintenanceRunner", () => { readonly shell: boolean | string | undefined; }> = []; return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); + const { registry } = yield* makeRegistry(baseProvider, { + installedVersionAfterUpdate: "1.0.0", + }); const runner = yield* makeTestRunner(registry); const result = yield* runner.updateProvider(CODEX_DRIVER); @@ -652,7 +869,7 @@ describe("providerMaintenanceRunner", () => { Layer.succeed(SpawnExecutableResolution, (command) => command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, ), - layerLatestVersionHttpClient("0.0.0"), + layerLatestVersionHttpClient("1.0.0"), Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { @@ -689,8 +906,14 @@ it.effect.each([ status: "succeeded", }, { - mode: "unknown", + mode: "targeted-current", version: "1.14.20", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, + { + mode: "unknown", + version: "1.14.19", installation: { installedVersion: null }, status: "unchanged", }, @@ -723,6 +946,10 @@ it.effect.each([ const updater = yield* makeTestRunner( { ...registry, + refreshInstance: () => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, checkedAt: "2026-04-10T00:00:01.000Z" })), + ), getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), refreshAfterUpdate: () => Effect.gen(function* () { @@ -786,7 +1013,7 @@ it.effect( "uses fresh installer versions for post-update verification and compatibility checks", () => { const calls: string[] = []; - let latest = "1.0.0"; + let latest = "2.0.0"; const manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: {}, diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 9dddf23579d8a..1450a837f75e5 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -83,6 +83,7 @@ class ProviderMaintenanceCommandError extends Data.TaggedError("ProviderMaintena interface VerifiedProviderRefresh { readonly providers: ReadonlyArray; readonly verifiedProviders: ReadonlyArray; + readonly advisoryEnriched: boolean; } const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -315,12 +316,14 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), }); - const verifyRefreshedProvider = ( + const verifyFromRefresh = ( + refresh: Effect.Effect>, provider: ProviderDriverKind, maintenanceCapabilities: ProviderMaintenanceCapabilities, instanceId: ProviderInstanceId, + phase: "pre-update" | "post-update", ): Effect.Effect => - providerRegistry.refreshAfterUpdate(provider, instanceId).pipe( + refresh.pipe( Effect.flatMap((providers) => { const refreshedProviders = providers.filter( (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, @@ -329,6 +332,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { return Effect.succeed({ providers, verifiedProviders: [], + advisoryEnriched: true, }); } return Effect.forEach( @@ -348,15 +352,18 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ providers, verifiedProviders, + advisoryEnriched: true, })), Effect.catchCause((cause) => - Effect.logWarning("Provider post-update version verification failed", { + Effect.logWarning("Provider version verification failed", { provider, + phase, cause: Cause.pretty(cause), }).pipe( Effect.as({ providers, verifiedProviders: refreshedProviders, + advisoryEnriched: false, }), ), ), @@ -481,6 +488,41 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); } + + if (targetVersion === undefined) { + const prior = (yield* providerRegistry.getProviders).find( + (candidate) => candidate.instanceId === instanceId, + ); + const precheck = yield* verifyFromRefresh( + providerRegistry.refreshInstance(instanceId), + provider, + fresh, + instanceId, + "pre-update", + ); + const alreadyCurrent = + precheck.advisoryEnriched && + prior !== undefined && + precheck.verifiedProviders.length > 0 && + precheck.verifiedProviders.every( + (verified) => + verified.checkedAt !== prior.checkedAt && + verified.installed && + verified.status !== "error" && + verified.versionAdvisory?.status === "current", + ); + if (alreadyCurrent) { + return yield* finish( + makeUpdateState({ + status: "succeeded", + startedAt, + finishedAt: yield* nowIso, + message: "Already up to date.", + }), + ); + } + } + yield* setRunningMessage(`Running ${describeCommand(command)}`); const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; @@ -512,10 +554,12 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { yield* invalidateLatestProviderVersion(verified).pipe( Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), ); - const { verifiedProviders } = yield* verifyRefreshedProvider( + const { verifiedProviders } = yield* verifyFromRefresh( + providerRegistry.refreshAfterUpdate(provider, instanceId), provider, verified, instanceId, + "post-update", ); // Cursor about probes can fail transiently; missing versions alone do not imply failure. const couldNotVerify =