diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts
index 4eccc95bc94f..666f113034a7 100644
--- a/packages/cli/src/services/server-connection.ts
+++ b/packages/cli/src/services/server-connection.ts
@@ -49,10 +49,12 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Resolved
})
+/** Passive reconnect: ensure-running with no version replacement authority. */
+export const managedReconnect = (options: EnsureOptions) => Service.ensure({ ...options, version: undefined })
+
function managedService(options: EnsureOptions) {
- const reconnectOptions = { ...options, version: undefined }
return {
- reconnect: () => Service.ensure(reconnectOptions),
+ reconnect: () => managedReconnect(options),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts
index 41a061f57c03..b72ffae5c707 100644
--- a/packages/cli/test/server-connection.test.ts
+++ b/packages/cli/test/server-connection.test.ts
@@ -1,14 +1,65 @@
import { NodeFileSystem } from "@effect/platform-node"
+import type { EnsureReason } from "@opencode-ai/client/effect/service"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { expect, test } from "bun:test"
-import { Effect, FileSystem, Scope } from "effect"
+import { Effect, Exit, FileSystem, Scope } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ServerConnection } from "../src/services/server-connection"
import { ServiceConfig } from "../src/services/service-config"
+const runReconnect = (effect: Effect.Effect) =>
+ Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
+
+test("managed reconnect ensures the service on the first failure", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-reconnect-"))
+ const starts: EnsureReason[] = []
+ try {
+ // Short-circuit ensure once it decides to spawn — we only need the reason.
+ const exit = await runReconnect(
+ ServerConnection.managedReconnect({
+ file: path.join(root, "service.json"),
+ onStart: (reason) => {
+ starts.push(reason)
+ throw new Error("service ensure invoked")
+ },
+ }).pipe(Effect.exit),
+ )
+ expect(Exit.isFailure(exit)).toBe(true)
+ expect(starts).toEqual(["missing"])
+ } finally {
+ await fs.rm(root, { recursive: true, force: true })
+ }
+})
+
+test("managed reconnect reuses a healthy service from another version", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-reconnect-version-"))
+ const server = Bun.serve({
+ port: 0,
+ fetch() {
+ return Response.json({ healthy: true, version: "older", pid: process.pid })
+ },
+ })
+ const file = path.join(root, "service.json")
+ const url = server.url.toString()
+ await Bun.write(file, JSON.stringify({ url, pid: process.pid, version: "older" }))
+ try {
+ const endpoint = await runReconnect(
+ ServerConnection.managedReconnect({
+ file,
+ version: "newer",
+ command: [path.join(root, "must-not-start")],
+ }),
+ )
+ expect(endpoint.url).toBe(url)
+ } finally {
+ await server.stop(true)
+ await fs.rm(root, { recursive: true, force: true })
+ }
+})
+
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-"))
const id = "server-resolution-test"