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"