Просмотр исходного кода

fix(cli): confirm managed service downgrades

Kit Langton 4 дней назад
Родитель
Сommit
88a9ee5d11

+ 4 - 0
bun.lock

@@ -188,12 +188,14 @@
       "dependencies": {
         "@opencode-ai/protocol": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
+        "semver": "catalog:",
       },
       "devDependencies": {
         "@effect/platform-node": "catalog:",
         "@opencode-ai/httpapi-codegen": "workspace:*",
         "@tsconfig/bun": "catalog:",
         "@types/bun": "catalog:",
+        "@types/semver": "catalog:",
         "@typescript/native-preview": "catalog:",
         "effect": "catalog:",
       },
@@ -6348,6 +6350,8 @@
 
     "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
 
+    "@opencode-ai/client/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
     "@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
 
     "@opencode-ai/console-app/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],

+ 7 - 0
packages/cli/src/commands/handlers/default.ts

@@ -23,7 +23,14 @@ export default Runtime.handler(Commands, (input) =>
       server: Option.getOrUndefined(input.server),
       standalone: input.standalone,
       mismatch: "replace",
+      confirmDowngrade: (serverVersion) =>
+        Effect.promise(async () => {
+          const confirmed = await preflight.confirmDowngrade(serverVersion)
+          if (confirmed) preflight.begin(serverVersion)
+          return confirmed
+        }),
       onStart: (reason, previousVersion) => {
+        if (preflight.active()) return
         if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
         process.stderr.write(
           reason === "version-mismatch"

+ 37 - 5
packages/cli/src/services/server-connection.ts

@@ -1,7 +1,7 @@
-import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
+import { Service, VersionMismatchError, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
 import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
 import { OPENCODE_VERSION } from "../version"
-import { Effect, Redacted } from "effect"
+import { Effect, Redacted, Result } from "effect"
 import { Env } from "../env"
 import { ServiceConfig } from "./service-config"
 import { Standalone } from "./standalone"
@@ -11,6 +11,7 @@ export type Args = {
   readonly standalone?: boolean
   readonly mismatch?: "replace" | "ignore" | "error"
   readonly onStart?: EnsureOptions["onStart"]
+  readonly confirmDowngrade?: (serverVersion: string, clientVersion: string) => Effect.Effect<boolean>
 }
 
 export type Resolved = {
@@ -45,7 +46,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
   const mismatch = args.mismatch ?? "ignore"
   const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
   return {
-    endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
+    endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch, args.confirmDowngrade),
     service: managedService(options),
   } satisfies Resolved
 })
@@ -62,8 +63,16 @@ function managedService(options: EnsureOptions) {
   }
 }
 
-const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
-  if (mismatch === "replace") return yield* Service.ensure(options)
+const resolveManaged = Effect.fnUntraced(function* (
+  options: EnsureOptions,
+  mismatch: NonNullable<Args["mismatch"]>,
+  confirmDowngrade?: Args["confirmDowngrade"],
+) {
+  if (mismatch === "replace") {
+    const result = yield* Effect.result(Service.ensure(options))
+    if (Result.isSuccess(result)) return result.success
+    return yield* confirmManagedDowngrade(options, result.failure, confirmDowngrade)
+  }
   if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
 
   const compatible = yield* Service.discover(options)
@@ -74,6 +83,29 @@ const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mism
   return yield* Service.ensure(options)
 })
 
+export const confirmManagedDowngrade = Effect.fnUntraced(function* (
+  options: EnsureOptions,
+  error: unknown,
+  confirm?: Args["confirmDowngrade"],
+) {
+  if (
+    !(error instanceof VersionMismatchError) ||
+    error.serverVersion === undefined ||
+    error.clientVersion === undefined ||
+    !Service.canReplaceVersion(error.clientVersion, error.serverVersion) ||
+    confirm === undefined
+  )
+    return yield* Effect.fail(error)
+  if (!(yield* confirm(error.serverVersion, error.clientVersion)))
+    return yield* Effect.fail(
+      new Error(`${error.message}. Run \`opencode2 service restart\` to activate this installed version.`, {
+        cause: error,
+      }),
+    )
+  yield* Service.stop(options)
+  return yield* Service.ensure(options)
+})
+
 function connectError(endpoint: Endpoint, cause: unknown) {
   if (isUnauthorizedError(cause)) {
     return new Error(

+ 2 - 8
packages/cli/src/services/service-config.ts

@@ -5,7 +5,6 @@ import { Service } from "@opencode-ai/client/effect/service"
 import { Effect, FileSystem, Option, Schema } from "effect"
 import { randomBytes } from "crypto"
 import path from "path"
-import semver from "semver"
 import { selfCommand } from "../util/process"
 
 // The CLI's service configuration file, plus the Service.EnsureOptions binding that
@@ -105,18 +104,13 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
   return {
     file,
     version: input.checkVersion ? OPENCODE_VERSION : undefined,
-    canReplace: (version: string | undefined) => canReplaceVersion(version),
+    canReplace: (version: string | undefined) => Service.canReplaceVersion(version, OPENCODE_VERSION),
     command: [...selfCommand(), "serve", "--service"],
   }
 })
 
 export function canReplaceVersion(serverVersion: string | undefined, clientVersion = OPENCODE_VERSION) {
-  if (serverVersion === undefined) return false
-  // Compare preview build numbers numerically rather than as semver prerelease strings.
-  const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
-  const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
-  if (!semver.valid(server) || !semver.valid(client)) return false
-  return semver.lt(server, client)
+  return Service.canReplaceVersion(serverVersion, clientVersion)
 }
 
 export const read = Effect.fn("cli.service-config.read")(function* () {

+ 53 - 1
packages/cli/src/services/update-preflight.tsx

@@ -28,7 +28,9 @@ const transitionDuration = 420
 const completionHold = 650
 
 export type Handle = {
+  readonly active: () => boolean
   readonly begin: (from?: string) => boolean
+  readonly confirmDowngrade: (from: string) => Promise<boolean>
   readonly loading: () => void
   readonly finish: () => Promise<Handoff | undefined>
   readonly fail: (message: string) => Promise<void>
@@ -44,6 +46,7 @@ export type Handoff = {
 export const make = (): Handle => {
   let session: Promise<Session | undefined> | undefined
   return {
+    active: () => session !== undefined,
     begin: (from) => {
       if (!process.stdout.isTTY || !process.stdin.isTTY) return false
       session ??= open(from).catch(() => {
@@ -52,6 +55,7 @@ export const make = (): Handle => {
       })
       return true
     },
+    confirmDowngrade: (from) => confirmDowngrade(from),
     loading: () => {
       void session?.then((active) => active?.loading())
     },
@@ -197,6 +201,40 @@ async function open(from?: string): Promise<Session> {
   }
 }
 
+async function confirmDowngrade(from: string) {
+  if (!process.stdout.isTTY || !process.stdin.isTTY) return false
+  const renderer = await createCliRenderer({
+    stdin: process.stdin,
+    useMouse: false,
+    autoFocus: true,
+    openConsoleOnError: false,
+    exitOnCtrlC: false,
+    screenMode: "split-footer",
+    footerHeight: 4,
+    targetFps: 30,
+    useKittyKeyboard: {},
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  const result = Promise.withResolvers<boolean>()
+  const onKeypress = (event: { readonly name: string; readonly ctrl: boolean }) => {
+    if (event.name === "return") return finish(true)
+    if (event.name === "escape" || (event.ctrl && event.name === "c")) finish(false)
+  }
+  const finish = (confirmed: boolean) => {
+    renderer.keyInput.off("keypress", onKeypress)
+    if (!renderer.isDestroyed) renderer.destroy()
+    result.resolve(confirmed)
+  }
+  renderer.keyInput.on("keypress", onKeypress)
+  await render(() => <DowngradeFooter from={from} />, renderer).catch((error) => {
+    renderer.keyInput.off("keypress", onKeypress)
+    if (!renderer.isDestroyed) renderer.destroy()
+    throw error
+  })
+  return result.promise
+}
+
 const colors = {
   accent: RGBA.fromHex("#a6b8ff"),
   accentBright: RGBA.fromHex("#eef1ff"),
@@ -447,7 +485,7 @@ function UpdateFooter(props: {
   })
 
   return (
-    <box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
+    <box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
       <Monogram ink={monogramInk} />
       <box flexDirection="column" flexGrow={1} overflow="hidden">
         <CellLine cells={header()} />
@@ -471,6 +509,20 @@ function UpdateFooter(props: {
   )
 }
 
+function DowngradeFooter(props: { from: string }) {
+  return (
+    <box width="100%" height={4} flexDirection="column" paddingLeft={1}>
+      <text fg={colors.text}>
+        <span style={{ fg: colors.muted }}>Background service </span>
+        <span style={{ fg: colors.accent }}>{props.from}</span>
+        <span style={{ fg: colors.muted }}> is newer than installed </span>
+        <span style={{ fg: colors.accent }}>{OPENCODE_VERSION}</span>
+      </text>
+      <text fg={colors.muted}>Press Enter to downgrade and restart · Esc to cancel</text>
+    </box>
+  )
+}
+
 function CellLine(props: { cells: ReadonlyArray<Cell> }) {
   return (
     <text truncate>

+ 33 - 0
packages/cli/test/server-connection.test.ts

@@ -8,6 +8,7 @@ import os from "node:os"
 import path from "node:path"
 import { ServerConnection } from "../src/services/server-connection"
 import { ServiceConfig } from "../src/services/service-config"
+import { VersionMismatchError } from "@opencode-ai/client/effect/service"
 
 test("resolution groups Effect-native lifecycle operations only for the managed service", async () => {
   const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-resolution-"))
@@ -69,3 +70,35 @@ test("service options only require a matching version when requested", async ()
     await fs.rm(root, { recursive: true, force: true })
   }
 })
+
+test("downgrade confirmation is offered only when the running service is newer", async () => {
+  const options = { version: "0.0.0-next-17271" }
+  const offered: string[] = []
+  const confirm = (serverVersion: string) =>
+    Effect.sync(() => {
+      offered.push(serverVersion)
+      return false
+    })
+
+  await expect(
+    Effect.runPromise(
+      ServerConnection.confirmManagedDowngrade(
+        options,
+        new VersionMismatchError(options.version, "0.0.0-next-17272"),
+        confirm,
+      ).pipe(Effect.provide(NodeFileSystem.layer)),
+    ),
+  ).rejects.toThrow("Run `opencode2 service restart`")
+  expect(offered).toEqual(["0.0.0-next-17272"])
+
+  await expect(
+    Effect.runPromise(
+      ServerConnection.confirmManagedDowngrade(
+        { version: "0.0.0-next-17272" },
+        new VersionMismatchError("0.0.0-next-17272", "0.0.0-next-17271"),
+        confirm,
+      ).pipe(Effect.provide(NodeFileSystem.layer)),
+    ),
+  ).rejects.toThrow("does not match")
+  expect(offered).toEqual(["0.0.0-next-17272"])
+})

+ 3 - 1
packages/client/package.json

@@ -33,7 +33,8 @@
   },
   "dependencies": {
     "@opencode-ai/schema": "workspace:*",
-    "@opencode-ai/protocol": "workspace:*"
+    "@opencode-ai/protocol": "workspace:*",
+    "semver": "catalog:"
   },
   "peerDependencies": {
     "effect": "4.0.0-beta.101"
@@ -49,6 +50,7 @@
     "@tsconfig/bun": "catalog:",
     "@types/bun": "catalog:",
     "@typescript/native-preview": "catalog:",
+    "@types/semver": "catalog:",
     "effect": "catalog:"
   }
 }

+ 34 - 41
packages/client/src/effect/service.ts

@@ -2,7 +2,14 @@ import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
 import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
 import { homedir } from "node:os"
 import { join } from "node:path"
-import { VersionMismatchError, type DiscoverOptions, type Endpoint, type EnsureOptions, type StopOptions } from "../service.js"
+import {
+  canReplaceVersion,
+  VersionMismatchError,
+  type DiscoverOptions,
+  type Endpoint,
+  type EnsureOptions,
+  type StopOptions,
+} from "../service.js"
 import {
   contenderFailure,
   contenderFinished,
@@ -86,10 +93,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
       if (compatible && service.state === "failed")
         return yield* Effect.fail(new Error("Background service failed to start"))
       if (compatible) return Option.none<LocalService>()
-      if (!service.legacy && options.canReplace?.(service.version) === false)
-        return yield* Effect.fail(new VersionMismatchError(options.version, service.version))
+      if (options.canReplace?.(service.version) !== true)
+        return yield* Effect.fail(
+          new VersionMismatchError(typeof options.version === "string" ? options.version : undefined, service.version),
+        )
+      yield* kill(service, timing)
       yield* announce("version-mismatch", service.version)
-      yield* kill(service, options, timing)
       lastSpawn = 0
       return Option.none<LocalService>()
     } else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -123,9 +132,11 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
 /** Stop the registered local service. */
 export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
   const existing = yield* find(options)
-  if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
+  if (existing !== undefined) yield* kill(existing, defaultEnsureTiming)
   if (existing === undefined && (yield* read(options.file)) !== undefined)
-    return yield* Effect.fail(new Error("Background service is not responding; stop its process manually and try again"))
+    return yield* Effect.fail(
+      new Error("Background service is not responding; stop its process manually and try again"),
+    )
 })
 
 function fallback() {
@@ -170,7 +181,7 @@ type LocalService = {
 }
 
 const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
-  return (yield* probeResult(info, allowLegacy)).service
+  return yield* probeResult(info, allowLegacy)
 })
 
 const probeResult = Effect.fnUntraced(function* (
@@ -197,41 +208,34 @@ const probeResult = Effect.fnUntraced(function* (
         (cause: unknown) => ({ cause }),
       ),
   )
-  if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
+  if ("cause" in result) return undefined
   const response = result.value.response
   const body = result.value.body
   const health = decodeHealth(body)
   if (Option.isSome(health)) {
-    if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
-    if (info.version !== undefined && health.value.version !== info.version)
-      return { service: undefined, timedOut: false }
+    if (health.value.pid !== info.pid) return undefined
+    if (info.version !== undefined && health.value.version !== info.version) return undefined
     return {
-      service: {
-        info,
-        endpoint,
-        version: health.value.version,
-        state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
-        legacy: false,
-      } satisfies LocalService,
-      timedOut: false,
-    }
+      info,
+      endpoint,
+      version: health.value.version,
+      state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
+      legacy: false,
+    } satisfies LocalService
   }
   if (
     !allowLegacy ||
     Option.isNone(decodeLegacyHealth(body)) ||
     (typeof body === "object" && body !== null && ("version" in body || "pid" in body))
   )
-    return { service: undefined, timedOut: false }
-  return {
-    service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
-    timedOut: false,
-  }
+    return undefined
+  return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
 })
 
 const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false, timeout?: number) {
   const info = yield* read(file)
-  if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
-  return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
+  if (info === undefined) return { info: undefined, service: undefined }
+  return { info, service: yield* probeResult(info, allowLegacy, timeout) }
 })
 
 // Health-checked lookup without the version gate: lifecycle operations must be
@@ -240,8 +244,7 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
   return (yield* registered(options.file, true)).service
 })
 
-// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
-// discovery window.
+// Poll until an authenticated stop exits, bounded by the configured stop window.
 const poll = (timing: EnsureTiming) =>
   Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
 
@@ -253,17 +256,7 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
   return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
 })
 
-function same(left: Info, right: Info) {
-  return (
-    left.id === right.id &&
-    left.version === right.version &&
-    left.url === right.url &&
-    left.pid === right.pid &&
-    left.password === right.password
-  )
-}
-
-const kill = Effect.fnUntraced(function* (service: LocalService, _options: { readonly file?: string }, timing: EnsureTiming) {
+const kill = Effect.fnUntraced(function* (service: LocalService, timing: EnsureTiming) {
   const requested = yield* requestStop(service, timing.requestTimeout)
   if (requested === "rejected") return yield* Effect.fail(new Error("Background service rejected the stop request"))
   if (requested === "unsupported") {
@@ -296,4 +289,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
 })
 
 /** Effect-based local service lifecycle operations. */
-export const Service = { discover, incumbent, ensure, stop, headers, Info }
+export const Service = { discover, incumbent, ensure, stop, headers, canReplaceVersion, Info }

+ 22 - 45
packages/client/src/promise/service.ts

@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"
 import { homedir } from "node:os"
 import { join } from "node:path"
 import {
+  canReplaceVersion,
   VersionMismatchError,
   type DiscoverOptions,
   type Endpoint,
@@ -17,7 +18,7 @@ import {
 } from "../service-contender.js"
 import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
 import { matchesVersion } from "../service-version.js"
-import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
+import type { ServiceStopResponse } from "./generated/types.js"
 
 export * from "../service.js"
 
@@ -74,10 +75,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
         if (compatible && service.state === "ready") return service.endpoint
         if (compatible && service.state === "failed") throw new Error("Background service failed to start")
         if (!compatible) {
-          if (!service.legacy && options.canReplace?.(service.version) === false)
-            throw new VersionMismatchError(options.version, service.version)
+          if (options.canReplace?.(service.version) !== true)
+            throw new VersionMismatchError(typeof options.version === "string" ? options.version : undefined, service.version)
+          await kill(service, timing)
           announce("version-mismatch", service.version)
-          await kill(service, options, timing)
           lastSpawn = 0
         }
       } else {
@@ -106,7 +107,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
 /** Stop the registered local service. */
 export async function stop(options: StopOptions = {}) {
   const existing = await find(options)
-  if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
+  if (existing !== undefined) await kill(existing, defaultEnsureTiming)
   if (existing === undefined && (await read(options.file)) !== undefined)
     throw new Error("Background service is not responding; stop its process manually and try again")
 }
@@ -149,10 +150,6 @@ type LocalService = {
   readonly legacy: boolean
 }
 
-async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
-  return (await probeResult(info, allowLegacy)).service
-}
-
 async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
   const endpoint = {
     url: info.url,
@@ -168,13 +165,13 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
   })
     .then(async (response) => ({
       response,
-      body: (await response.json()) as ServiceHealth | { readonly healthy: true },
+      body: (await response.json()) as unknown,
     }))
     .then(
       (value) => ({ value }),
       (cause: unknown) => ({ cause }),
     )
-  if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
+  if ("cause" in result) return undefined
   const response = result.value.response
   const body = result.value.body
   if (
@@ -189,18 +186,15 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
     Number.isInteger(body.pid) &&
     body.pid > 0
   ) {
-    if (body.pid !== info.pid) return { service: undefined, timedOut: false }
-    if (info.version !== undefined && body.version !== info.version) return { service: undefined, timedOut: false }
+    if (body.pid !== info.pid) return undefined
+    if (info.version !== undefined && body.version !== info.version) return undefined
     return {
-      service: {
-        info,
-        endpoint,
-        version: body.version,
-        state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
-        legacy: false,
-      } satisfies LocalService,
-      timedOut: false,
-    }
+      info,
+      endpoint,
+      version: body.version,
+      state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
+      legacy: false,
+    } satisfies LocalService
   }
   if (
     !allowLegacy ||
@@ -211,17 +205,14 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
     "version" in body ||
     "pid" in body
   )
-    return { service: undefined, timedOut: false }
-  return {
-    service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
-    timedOut: false,
-  }
+    return undefined
+  return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
 }
 
 async function registered(file?: string, allowLegacy = false, timeout?: number) {
   const info = await read(file)
-  if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
-  return { info, ...(await probeResult(info, allowLegacy, timeout)) }
+  if (info === undefined) return { info: undefined, service: undefined }
+  return { info, service: await probeResult(info, allowLegacy, timeout) }
 }
 
 async function find(options: { readonly file?: string }) {
@@ -245,21 +236,7 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
   return false
 }
 
-function same(left: Info, right: Info) {
-  return (
-    left.id === right.id &&
-    left.version === right.version &&
-    left.url === right.url &&
-    left.pid === right.pid &&
-    left.password === right.password
-  )
-}
-
-async function kill(
-  service: LocalService,
-  _options: { readonly file?: string },
-  timing: EnsureTiming,
-) {
+async function kill(service: LocalService, timing: EnsureTiming) {
   const requested = await requestStop(service, timing.requestTimeout)
   if (requested === "rejected") throw new Error("Background service rejected the stop request")
   if (requested === "unsupported") throw new Error("Background service does not support authenticated stop requests")
@@ -287,4 +264,4 @@ function delay(milliseconds: number) {
 }
 
 /** Promise-based local service lifecycle operations. */
-export const Service = { discover, ensure, stop, headers }
+export const Service = { discover, ensure, stop, headers, canReplaceVersion }

+ 14 - 5
packages/client/src/service.ts

@@ -1,3 +1,5 @@
+import semver from "semver"
+
 /** Connection details for a local OpenCode service. */
 export type Endpoint = {
   /** Base URL of the service. */
@@ -28,7 +30,7 @@ export type EnsureReason = "missing" | "version-mismatch"
 export type EnsureOptions = DiscoverOptions & {
   /** Service command and arguments. Defaults to `opencode serve --service`. */
   readonly command?: ReadonlyArray<string>
-  /** Decide whether a version-mismatched service may be replaced. Defaults to true. */
+  /** Decide whether a version-mismatched service may be replaced. Defaults to false. */
   readonly canReplace?: (version: string | undefined) => boolean
   /** Called once before spawning a new service process. */
   readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
@@ -42,13 +44,20 @@ export class VersionMismatchError extends Error {
     readonly clientVersion: string | undefined,
     readonly serverVersion: string | undefined,
   ) {
-    super(
-      `Background service ${serverVersion ?? "unknown"} is newer than this client ${clientVersion ?? "unknown"}. ` +
-        "Run `opencode2 service restart` to activate this installed version.",
-    )
+    super(`Background service ${serverVersion ?? "unknown"} does not match client ${clientVersion ?? "unknown"}`)
   }
 }
 
+/** Whether a client version is strictly newer than a service version. */
+export function canReplaceVersion(serverVersion: string | undefined, clientVersion: string) {
+  if (serverVersion === undefined) return false
+  // Compare preview build numbers numerically rather than as semver prerelease strings.
+  const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
+  const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
+  if (!semver.valid(server) || !semver.valid(client)) return false
+  return semver.lt(server, client)
+}
+
 /** Options used to stop the local OpenCode service. */
 export type StopOptions = {
   /** Absolute registration file path. Defaults to the XDG state directory. */

+ 1 - 2
packages/client/test/promise-service.test.ts

@@ -187,10 +187,9 @@ test("a stale native client refuses to replace a newer service", async () => {
     ensure({
       file: registration,
       version: "old",
-      canReplace: () => false,
       command: [process.execPath, fixture, contender, "record-start"],
     }),
-  ).rejects.toThrow("Run `opencode2 service restart` to activate this installed version")
+  ).rejects.toThrow("Background service test does not match client old")
 
   expect(await Bun.file(contender + ".started").exists()).toBe(false)
   expect(process.kill(info.pid, 0)).toBe(true)

+ 15 - 7
packages/client/test/service.test.ts

@@ -175,6 +175,7 @@ test("does not spawn contenders while an incompatible service rejects replacemen
     ensure({
       file: registration,
       version: "test",
+      canReplace: () => true,
       command: [process.execPath, fixture, contender, "record-start"],
     }),
   )
@@ -196,6 +197,7 @@ test("does not signal a modern service when its stop request times out", async (
     ensure({
       file: registration,
       version: "test",
+      canReplace: () => true,
       command: [process.execPath, fixture, contender, "record-start"],
     }),
   )
@@ -213,7 +215,9 @@ test("explicit stop refuses to signal when a modern stop request times out", asy
   const existing = spawn(registration, "stop-hanging")
   await waitForFile(registration)
 
-  await expect(run(Service.stop({ file: registration }))).rejects.toThrow("Background service rejected the stop request")
+  await expect(run(Service.stop({ file: registration }))).rejects.toThrow(
+    "Background service rejected the stop request",
+  )
 
   expect(existing.exitCode).toBe(null)
 })
@@ -231,11 +235,10 @@ test("a stale client refuses to replace a newer service", async () => {
       ensure({
         file: registration,
         version: "old",
-        canReplace: () => false,
         command: [process.execPath, fixture, contender, "record-start"],
       }),
     ),
-  ).rejects.toThrow("Run `opencode2 service restart` to activate this installed version")
+  ).rejects.toThrow("Background service test does not match client old")
 
   expect(await Bun.file(contender + ".started").exists()).toBe(false)
   expect(existing.exitCode).toBe(null)
@@ -255,7 +258,7 @@ test("explicit restart can activate an installed downgrade", async () => {
     command: [process.execPath, fixture, registration, "old"],
   }
 
-  await expect(run(ensure(options))).rejects.toThrow("Run `opencode2 service restart`")
+  await expect(run(ensure(options))).rejects.toThrow("Background service test does not match client old")
   expect(current.exitCode).toBe(null)
 
   await run(Service.stop({ file: registration }))
@@ -279,10 +282,12 @@ test("refuses to signal a legacy service without authenticated stop", async () =
   await waitForFile(registration)
 
   const starts: EnsureReason[] = []
-  const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
+  const result = run(
+    ensure({ file: registration, command: [], canReplace: () => true, onStart: (reason) => starts.push(reason) }),
+  )
 
   await expect(result).rejects.toThrow("does not support authenticated stop requests")
-  expect(starts).toEqual(["version-mismatch"])
+  expect(starts).toEqual([])
   expect(existing.exitCode).toBe(null)
 })
 
@@ -392,6 +397,7 @@ test("replaces an incompatible owner that appears during startup", async () => {
     ensure({
       file: registration,
       version: "test",
+      canReplace: () => true,
       command: [process.execPath, fixture, registration, "delayed", "500"],
     }),
   )
@@ -424,7 +430,9 @@ test("concurrent current-version launchers converge on one replacement", async (
         command: [process.execPath, fixture, registration, "coordinated"],
         canReplace: (version: string | undefined) => version === "old",
       }
-      return index % 2 === 0 ? run(ensure(options)) : import("../src/promise/service").then((mod) => mod.Service.ensure(options))
+      return index % 2 === 0
+        ? run(ensure(options))
+        : import("../src/promise/service").then((mod) => mod.Service.ensure(options))
     }),
   )
   const info = await Bun.file(registration).json()

+ 1 - 0
packages/desktop/src/main/background-cli.ts

@@ -30,6 +30,7 @@ export async function startBackgroundCli(logger: Logger) {
         ? join(app.getPath("userData"), "opencode", "service-local.json")
         : undefined,
     version,
+    canReplace: (serverVersion) => Service.canReplaceVersion(serverVersion, version),
     command: [binary, "serve", "--service"],
     onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
   })

+ 3 - 0
packages/tui/src/component/migration-overlay.tsx

@@ -17,6 +17,7 @@ export function MigrationOverlay() {
 
   onMount(async () => {
     await Bun.sleep(1_000)
+    if (abort.signal.aborted) return
     void (async () => {
       while (true) {
         const result = await client.api.migration.v1.status({ signal: abort.signal }).then(
@@ -25,7 +26,9 @@ export function MigrationOverlay() {
         )
         if ("error" in result) {
           if (result.error instanceof ClientError && result.error.reason === "Transport") {
+            if (abort.signal.aborted) return
             await Bun.sleep(1_000)
+            if (abort.signal.aborted) return
             continue
           }
           throw result.error