Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 13 additions & 23 deletions packages/client/src/effect/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { contenderPool, spawnServiceContender } from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
import { decide, fallback, headers, probeResult, same } from "../service-probe.js"

export * from "../service.js"
export { headers }
Expand Down Expand Up @@ -89,30 +89,20 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} else timeouts = undefined
if (service !== undefined) {
pool.serviceAnswered()
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
if (!service.compatible && versionMatches)
return yield* Effect.fail(
new Error(
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
),
)
if (compatible && service.state === "ready") {
const decision = decide(service, options)
if (decision._tag === "fail") return yield* Effect.fail(decision.error)
if (decision._tag === "reuse") {
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
return Option.some(service)
}
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
if (service.state !== "ready")
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* stop({
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
pool.evict(service.info.pid)
return Option.none<LocalService>()
if (decision._tag === "replace") {
yield* announce("version-mismatch", service.version)
if (service.state !== "ready")
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* stop({ file: options.file, pty: decision.pty }).pipe(Effect.ignore)
pool.evict(service.info.pid)
}
return Option.none()
}

const failed = pool.reap()
Expand All @@ -121,7 +111,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
yield* announce("missing")
pool.add(yield* spawnContender)
}
return Option.none<LocalService>()
return Option.none()
}).pipe(
Effect.repeat({
until: Option.isSome,
Expand Down
20 changes: 6 additions & 14 deletions packages/client/src/promise/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { contenderPool, spawnServiceContender } from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { fallback, headers, probeResult, same } from "../service-probe.js"
import { decide, fallback, headers, probeResult, same } from "../service-probe.js"

export * from "../service.js"
export { headers }
Expand Down Expand Up @@ -71,25 +71,17 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (registration.service !== undefined) {
pool.serviceAnswered()
const service = registration.service
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
if (!service.compatible && versionMatches)
throw new Error(
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
)
if (compatible && service.state === "ready") {
const decision = decide(service, options)
if (decision._tag === "fail") throw decision.error
if (decision._tag === "reuse") {
await PtyHandoff.complete(options.file ?? fallback(), service.info)
return service.endpoint
}
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
if (decision._tag === "replace") {
announce("version-mismatch", service.version)
if (service.state !== "ready")
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await stop({
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
await stop({ file: options.file, pty: decision.pty }).catch(() => undefined)
pool.evict(service.info.pid)
}
} else {
Expand Down
23 changes: 22 additions & 1 deletion packages/client/src/service-probe.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { homedir } from "node:os"
import { join } from "node:path"
import type { Endpoint, Info } from "./service.js"
import type { DiscoverOptions, Endpoint, Info } from "./service.js"
import { defaultEnsureTiming } from "./service-timing.js"
import { matchesVersion } from "./service-version.js"

// Shared by the Effect and Promise clients. Keep this module free of Effect so the
// Promise client never loads the Effect runtime.
Expand All @@ -14,6 +15,26 @@ export type LocalService = {
readonly compatible: boolean
}

/**
* What `ensure()` does with a registered service that answered: return it, keep polling while
* it starts, replace it because its version does not meet the requirement, or fail.
*/
export function decide(service: LocalService, options: DiscoverOptions) {
if (!matchesVersion(service.version, options))
return { _tag: "replace", pty: service.state === "ready" ? "handoff" : "clear" } as const
if (!service.compatible)
return fail(
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
)
if (service.state === "ready") return { _tag: "reuse" } as const
if (service.state === "failed") return fail("Background service failed to start")
return { _tag: "wait" } as const
}

function fail(message: string) {
return { _tag: "fail" as const, error: new Error(message) }
}

/** The default registration file. */
export function fallback() {
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
Expand Down
54 changes: 54 additions & 0 deletions packages/client/test/service-decision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from "bun:test"
import { decide, type LocalService } from "../src/service-probe"

const protocolError =
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service."

function service(input: Pick<LocalService, "state" | "compatible" | "version">) {
return {
info: { url: "http://127.0.0.1:4096", pid: 4242, version: input.version },
endpoint: { url: "http://127.0.0.1:4096" },
...input,
} satisfies LocalService
}

test("a missing health endpoint fails unless the caller requires a different version", () => {
const missing = service({ version: "2.0.0", state: "ready", compatible: false })
expect(decide(missing, {})).toEqual({ _tag: "fail", error: new Error(protocolError) })
expect(decide(missing, { version: "2.0.0" })).toEqual({ _tag: "fail", error: new Error(protocolError) })
expect(decide(missing, { version: (version) => version.startsWith("2.") })).toEqual({
_tag: "fail",
error: new Error(protocolError),
})
expect(decide(missing, { version: "3.0.0" })).toEqual({ _tag: "replace", pty: "handoff" })
expect(decide(service({ state: "ready", compatible: false }), { version: "3.0.0" })).toEqual({
_tag: "replace",
pty: "handoff",
})
})

test("a compatible service is reused when ready, awaited while starting, and fails when it failed", () => {
expect(decide(service({ version: "2.0.0", state: "ready", compatible: true }), {})).toEqual({ _tag: "reuse" })
expect(decide(service({ version: "2.0.0", state: "waiting", compatible: true }), { version: "2.0.0" })).toEqual({
_tag: "wait",
})
expect(decide(service({ version: "2.0.0", state: "failed", compatible: true }), {})).toEqual({
_tag: "fail",
error: new Error("Background service failed to start"),
})
})

test("a version mismatch replaces the service, handing off terminals only when it is ready", () => {
expect(decide(service({ version: "1.0.0", state: "ready", compatible: true }), { version: "2.0.0" })).toEqual({
_tag: "replace",
pty: "handoff",
})
expect(decide(service({ version: "1.0.0", state: "waiting", compatible: true }), { version: "2.0.0" })).toEqual({
_tag: "replace",
pty: "clear",
})
expect(decide(service({ version: "1.0.0", state: "failed", compatible: true }), { version: "2.0.0" })).toEqual({
_tag: "replace",
pty: "clear",
})
})
Loading