refactor(cli): define server connection boundary (#37133)

This commit is contained in:
Kit Langton
2026-07-15 11:48:38 -04:00
committed by GitHub
parent e8bd386973
commit f5dd181443
14 changed files with 250 additions and 121 deletions
+93 -31
View File
@@ -30,6 +30,50 @@ This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery protocol negotiation, idle background restart, or general execution-recovery
framework. framework.
## Architecture at a Glance
```text
╭───────────────────╮
│ CLI ServiceConfig │
╰─────────┬─────────╯
╭──────────────────────╮
│ CLI ServerConnection │
╰───────────┬──────────╯
╭──────────────────╰───────────────────╮
▼ ▼
╭──────────────────────────╮ ╭─────────────────────────╮
│ Client Service lifecycle │ │ CLI runPromiseWith seam │
╰─────────────┬────────────╯ ╰─────────────┬───────────╯
╰─────╮ │
▼ ▼
╭────────────────────────────╮ ╭─────────────╮
│ Background service process │ │ TUI / Solid │
╰──────────────┬─────────────╯ ╰──────┬──────╯
│ │
╰────────────◀────────────────────╯
╭───────────────────────╮
│ Server HTTP transport │
╰───────────┬───────────╯
╭──────────────────╮
│ Core application │
╰──────────────────╯
```
| Owner | Responsibility |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations |
| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command |
| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects |
| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot |
| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport |
| `packages/core` | Application behavior behind the transport |
| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities |
| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI |
## Implementation Status ## Implementation Status
| Area | State | | Area | State |
@@ -164,19 +208,22 @@ This design gives each concept one authority.
## System Model ## System Model
```mermaid ```text
flowchart LR ╭───────────────────────╮ ╭──────────────────────────────╮
TUI[Fresh or existing TUI] Fresh or existing TUI │ │ Process-held OS service lock │
REG[Registration file] ╰───────────┬───────────╯ ╰───────────────┬──────────────╯
LOCK[Process-held OS service lock] ╰─────┬ normal requests observe ───────────────────────╮ │
SHELL[Lifecycle shell] │ discover │ ├──╯ authorizes one owner
APP[OpenCode application] ▼ │ ▼
╭───────────────────╮ │ ╭─────────────────╮
TUI -->|discover| REG │ Registration file │ │ │ Lifecycle shell │
TUI -->|observe| SHELL ╰───────────────────╯ │ ╰────────┬────────╯
TUI -->|normal requests| APP │ │
SHELL --> APP ├────────────────────────╯
LOCK -->|authorizes one owner| SHELL
╭──────────────────────╮
│ OpenCode application │
╰──────────────────────╯
``` ```
The lifecycle shell and application run in the same process. The distinction is The lifecycle shell and application run in the same process. The distinction is
@@ -311,24 +358,39 @@ Windows, where Bun FFI is not available on every shipped architecture. It lives
alongside the existing utility in `packages/core/src/util`. This primitive is alongside the existing utility in `packages/core/src/util`. This primitive is
the foundation of the design, so the delivery sequence spikes it first. the foundation of the design, so the delivery sequence spikes it first.
```mermaid ```text
flowchart TD Contender Lock Lifecycle Application
A[Contender process starts] │ │ │ │
B{Acquire service lock?} ├─ try acquire ───▶ │ │
C[Exit successfully] │ │ │ │
D[Bind lifecycle shell] ╭─ alt: lock held ────────────────────────────────────────────────╮
E[Write registration] │ │ │ │ │ │
F[Report starting] │ ◀─ busy ──────────┤ │ │ │
G[Initialize application] │ │ │ │ │ │
H[Report ready] │ ├─────────╮ │ │ │ │
I[Serve until shutdown] │ │ exit │ │ │ │ │
│ ◀─────────╯ │ │ │ │
A --> B │ │ │ │ │ │
B -->|No| C ├─ else: lock acquired ───────────────────────────────────────────┤
B -->|Yes| D │ │ │ │ │ │
D --> E --> F --> G │ ◀─ owner ─────────┤ │ │ │
G -->|Success| H --> I │ │ │ │ │ │
G -->|Failure| J[Report failed and stay bound] │ ├─ bind, register, starting ────────▶ │ │
│ │ │ │ │ │
│ ├─ initialize ──────────────────────────────────────────────▶ │
│ │ │ │ │ │
│╭─ alt: boot succeeds ──────────────────────────────────────────╮│
││ │ │ │ │ ││
││ │ │ ◀─ ready ───────────────┤ ││
││ │ │ │ │ ││
│├─ else: boot fails ────────────────────────────────────────────┤│
││ │ │ │ │ ││
││ │ │ ◀─ failed, stay bound ──┤ ││
││ │ │ │ │ ││
│╰───────────────────────────────────────────────────────────────╯│
│ │ │ │ │ │
╰─────────────────────────────────────────────────────────────────╯
│ │ │ │
``` ```
Lock acquisition by a contender is nonblocking or tightly bounded. A loser Lock acquisition by a contender is nonblocking or tightly bounded. A loser
+3 -7
View File
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { Server } from "../../services/server" import { ServerConnection } from "../../services/server-connection"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@@ -18,7 +18,7 @@ type OpenApi = {
export default Runtime.handler( export default Runtime.handler(
Commands.commands.api, Commands.commands.api,
Effect.fn("cli.api")(function* (input) { Effect.fn("cli.api")(function* (input) {
const server = yield* Server.resolve({ const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server), server: Option.getOrUndefined(input.server),
standalone: input.standalone, standalone: input.standalone,
mismatch: "ignore", mismatch: "ignore",
@@ -62,11 +62,7 @@ export function rawRequest(input: readonly string[]) {
return { method: input[0].toUpperCase(), path: input[1] } return { method: input[0].toUpperCase(), path: input[1] }
} }
function resolveRequest( function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) {
endpoint: Service.Endpoint,
input: readonly string[],
params: Record<string, string>,
) {
const raw = rawRequest(input) const raw = rawRequest(input)
if (raw) return Effect.succeed(raw) if (raw) return Effect.succeed(raw)
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
+16 -5
View File
@@ -4,8 +4,8 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Config } from "../../config" import { Config } from "../../config"
import { Effect, Option } from "effect" import { Context, Effect, FileSystem, Option } from "effect"
import { Server } from "../../services/server" import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight" import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
@@ -18,7 +18,7 @@ export default Runtime.handler(Commands, (input) =>
yield* updater.check().pipe(Effect.forkScoped) yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make() const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* Server.resolve({ const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server), server: Option.getOrUndefined(input.server),
standalone: input.standalone, standalone: input.standalone,
onStart: (reason, existing) => { onStart: (reason, existing) => {
@@ -37,11 +37,22 @@ export default Runtime.handler(Commands, (input) =>
preflight.loading() preflight.loading()
const config = yield* Config.Service const config = yield* Config.Service
const npm = yield* Npm.Service const npm = yield* Npm.Service
const context = yield* Effect.context() const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const context = yield* Effect.context<FileSystem.FileSystem>()
const runFork = Effect.runForkWith(context) const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context) const runPromise = Effect.runPromiseWith(context)
const service = server.service
yield* run({ yield* run({
server, server: {
endpoint: server.endpoint,
service: service
? {
reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }),
restart: () => runServicePromise(service.restart()),
}
: undefined,
},
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config: { config: {
path: config.path, path: config.path,
+2 -2
View File
@@ -1,14 +1,14 @@
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server" import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(Commands.commands.mini, (input) => export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini")) const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
yield* Effect.promise(async () => validateMiniTerminal()) yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server) const serverURL = Option.getOrUndefined(input.server)
const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone }) const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
yield* Effect.promise(() => yield* Effect.promise(() =>
runMini({ runMini({
server, server,
+2 -2
View File
@@ -1,13 +1,13 @@
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server" import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(Commands.commands.run, (input) => export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini")) const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const separator = process.argv.indexOf("--", 2) const separator = process.argv.indexOf("--", 2)
const server = yield* Server.resolve({ const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server), server: Option.getOrUndefined(input.server),
standalone: input.standalone, standalone: input.standalone,
}) })
+3 -6
View File
@@ -1,12 +1,12 @@
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Server } from "../services/server" import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "./catalog.shared" import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types" import type { RunInput, RunTuiConfig } from "./types"
export type MiniCommandInput = { export type MiniCommandInput = {
server: Server.Resolved server: ServerConnection.Resolved
continue?: boolean continue?: boolean
session?: string session?: string
fork?: boolean fork?: boolean
@@ -38,10 +38,7 @@ export async function runMini(input: MiniCommandInput) {
return agentTask return agentTask
} }
const resolveSession = async () => { const resolveSession = async () => {
const [agent, selected] = await Promise.all([ const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
resolveAgent(),
selectSession(sdk, directory, input),
])
const readyModel = const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel }) if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
+3 -4
View File
@@ -4,7 +4,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model" import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises" import { open } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Server } from "../services/server" import { ServerConnection } from "../services/server-connection"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive" import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool" import { toolInlineInfo } from "./tool"
@@ -12,7 +12,7 @@ import type { MiniToolPart } from "./types"
import { UI } from "./ui" import { UI } from "./ui"
export type RunCommandInput = { export type RunCommandInput = {
server: Server.Resolved server: ServerConnection.Resolved
message: string[] message: string[]
continue?: boolean continue?: boolean
session?: string session?: string
@@ -73,8 +73,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined : undefined
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel) const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (variant && !model) if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) { if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } }) const available = await client.model.list({ location: { directory: cwd, workspace } })
@@ -1,4 +1,3 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -16,11 +15,10 @@ export type Args = {
export type Resolved = { export type Resolved = {
readonly endpoint: Service.Endpoint readonly endpoint: Service.Endpoint
readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint> readonly service?: ReturnType<typeof managedService>
readonly reload?: () => Promise<void>
} }
export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
if (args.server !== undefined && args.standalone) if (args.server !== undefined && args.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
if (args.server !== undefined) { if (args.server !== undefined) {
@@ -45,24 +43,24 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
} }
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace")
const reconnectOptions = { ...options, version: undefined }
return { return {
endpoint, endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
reconnect: (onStatus, signal) => service: managedService(options),
Effect.runPromise(Service.start({ ...reconnectOptions, onStatus }).pipe(Effect.provide(NodeFileSystem.layer)), {
signal,
}),
reload: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.start(options)
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
} satisfies Resolved } satisfies Resolved
}) })
function managedService(options: Service.StartOptions) {
const reconnectOptions = { ...options, version: undefined }
return {
reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.start(options)
}),
}
}
const resolveManaged = Effect.fnUntraced(function* ( const resolveManaged = Effect.fnUntraced(function* (
options: Service.StartOptions, options: Service.StartOptions,
mismatch: NonNullable<Args["mismatch"]>, mismatch: NonNullable<Args["mismatch"]>,
@@ -92,4 +90,4 @@ function connectError(endpoint: Service.Endpoint, cause: unknown) {
return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause }) return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause })
} }
export * as Server from "./server" export * as ServerConnection from "./server-connection"
@@ -0,0 +1,59 @@
import { NodeFileSystem } from "@effect/platform-node"
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 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"
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"
const server = Bun.serve({
port: 0,
fetch() {
return Response.json({
healthy: true,
version: InstallationVersion,
pid: process.pid,
instanceID: id,
status: { type: "ready" },
})
},
})
const registration = path.join(root, "state", ServiceConfig.filename())
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
try {
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(
registration,
JSON.stringify({
id,
version: InstallationVersion,
url: server.url.toString(),
pid: process.pid,
}),
)
const resolved = await runPromise(ServerConnection.resolve({}))
expect(resolved.endpoint.url).toBe(server.url.toString())
expect(resolved.service).toBeDefined()
if (!resolved.service) throw new Error("Expected managed service capabilities")
expect(Effect.isEffect(resolved.service.reconnect(() => {}))).toBe(true)
expect(Effect.isEffect(resolved.service.restart())).toBe(true)
expect(await runPromise(resolved.service.reconnect(() => {}))).toEqual(resolved.endpoint)
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
expect(explicit.endpoint.url).toBe(server.url.toString())
expect(explicit.service).toBeUndefined()
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
+8 -8
View File
@@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues."
--- ---
<Tip> <Tip>
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read
steps below, inspect its service and logs, and help identify the issue. the steps below, inspect its service and logs, and help identify the issue.
</Tip> </Tip>
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
@@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it:
opencode2 service restart opencode2 service restart
``` ```
From inside the TUI, run `/reload` to restart the managed service and reconnect: From inside the TUI, run `/restart` to restart the managed service and reconnect:
```text ```text
/reload /restart
``` ```
You can also stop and start it explicitly: You can also stop and start it explicitly:
@@ -45,8 +45,8 @@ opencode2 service start
``` ```
<Note> <Note>
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed OpenCode normally discovers or starts the shared background service automatically. The service commands are only
when diagnosing its lifecycle. needed when diagnosing its lifecycle.
</Note> </Note>
## Run an isolated session ## Run an isolated session
@@ -125,8 +125,8 @@ The database normally lives at:
`OPENCODE_DB` can override the database location. `OPENCODE_DB` can override the database location.
<Warning> <Warning>
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon, Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
and make a backup before inspecting persistent data with external tools. daemon, and make a backup before inspecting persistent data with external tools.
</Warning> </Warning>
## Explicit servers ## Explicit servers
+25 -19
View File
@@ -118,6 +118,7 @@ const appBindingCommands = [
"provider.connect", "provider.connect",
"opencode.status", "opencode.status",
"server.pair", "server.pair",
"service.restart",
"opencode.debug", "opencode.debug",
"theme.switch", "theme.switch",
"theme.switch_mode", "theme.switch_mode",
@@ -138,8 +139,10 @@ const appBindingCommands = [
export type TuiInput = { export type TuiInput = {
server: { server: {
endpoint: Service.Endpoint endpoint: Service.Endpoint
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint> service?: {
reload?: () => Promise<void> reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
restart: () => Promise<void>
}
} }
args: Args args: Args
config: Config.Interface config: Config.Interface
@@ -183,14 +186,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))), Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
) )
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const reconnectEndpoint = input.server.reconnect const managed = input.server.service
const reconnect = reconnectEndpoint const service = managed
? async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => { ? {
const endpoint = await reconnectEndpoint(onStatus, signal) reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } const endpoint = await managed.reconnect(onStatus, signal)
return { const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
api: OpenCode.make(next), return { api: OpenCode.make(next) }
} },
restart: managed.restart,
} }
: undefined : undefined
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
@@ -324,7 +328,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
} }
> >
<PluginRuntimeProvider value={pluginRuntime}> <PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}> <ClientProvider api={api} service={service}>
<PermissionProvider> <PermissionProvider>
<DataProvider> <DataProvider>
<LocationProvider> <LocationProvider>
@@ -757,19 +761,21 @@ function App(props: { pair?: DialogPairCredentials }) {
}, },
category: "System", category: "System",
}, },
...(client.reload ...(client.restart
? [ ? [
{ {
name: "server.reload", name: "service.restart",
title: "Reload server", title: "Restart service",
slash: { name: "reload" }, slash: { name: "restart" },
run: async () => { run: async () => {
const restart = client.restart
if (!restart) return
dialog.clear() dialog.clear()
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) toast.show({ variant: "info", message: "Restarting service...", duration: 30000 })
// reload resolves once the replacement service is healthy; the // restart resolves once the replacement service is healthy; the
// event stream reattaches through the reconnect loop. // event stream reattaches through the reconnect loop.
await client.reload!() await restart()
.then(() => toast.show({ variant: "success", message: "Server reloaded" })) .then(() => toast.show({ variant: "success", message: "Service restarted" }))
.catch(toast.error) .catch(toast.error)
}, },
category: "System", category: "System",
+9 -9
View File
@@ -18,18 +18,18 @@ export type ClientConnectionEvent = {
} }
} }
type ManagedService = {
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> } type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
const connectTimeout = 2_000 const connectTimeout = 2_000
const connectionHistoryLimit = 50 const connectionHistoryLimit = 50
export const { use: useClient, provider: ClientProvider } = createSimpleContext({ export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client", name: "Client",
init: (props: { init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
api: OpenCodeClient
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
// Stops and starts the managed service; present only in service mode.
reload?: () => Promise<void>
}) => {
const log = useLog({ component: "client" }) const log = useLog({ component: "client" })
const abort = new AbortController() const abort = new AbortController()
const history: ClientConnectionEvent[] = [] const history: ClientConnectionEvent[] = []
@@ -115,8 +115,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
// Re-resolve the transport before retrying: the server may have // Re-resolve the transport before retrying: the server may have
// moved (service restarted on a new port) or need starting. Static // moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address. // transports (--server, standalone) resolve to the same address.
if (props.reconnect) { if (props.service) {
const next = await props.reconnect(setService, controller.signal).catch((error) => { const next = await props.service.reconnect(setService, controller.signal).catch((error) => {
if (!controller.signal.aborted) if (!controller.signal.aborted)
log.info("server resolution failed", { log.info("server resolution failed", {
attempt, attempt,
@@ -168,7 +168,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
}, },
}, },
}, },
reload: props.reload, restart: props.service?.restart,
} }
}, },
}) })
+2 -1
View File
@@ -65,10 +65,11 @@ async function mount(
const ready = new Promise<void>((resolve) => { const ready = new Promise<void>((resolve) => {
done = resolve done = resolve
}) })
const service = reconnect ? { reconnect, restart: () => Promise.resolve() } : undefined
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts log={log}> <TestTuiContexts log={log}>
<ClientProvider api={createApi(calls.fetch)} reconnect={reconnect}> <ClientProvider api={createApi(calls.fetch)} service={service}>
<Probe <Probe
onReady={(ctx) => { onReady={(ctx) => {
client = ctx.client client = ctx.client
@@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues."
--- ---
<Tip> <Tip>
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read
steps below, inspect its service and logs, and help identify the issue. the steps below, inspect its service and logs, and help identify the issue.
</Tip> </Tip>
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
@@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it:
opencode2 service restart opencode2 service restart
``` ```
From inside the TUI, run `/reload` to restart the managed service and reconnect: From inside the TUI, run `/restart` to restart the managed service and reconnect:
```text ```text
/reload /restart
``` ```
You can also stop and start it explicitly: You can also stop and start it explicitly:
@@ -45,8 +45,8 @@ opencode2 service start
``` ```
<Note> <Note>
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed OpenCode normally discovers or starts the shared background service automatically. The service commands are only
when diagnosing its lifecycle. needed when diagnosing its lifecycle.
</Note> </Note>
## Run an isolated session ## Run an isolated session
@@ -125,8 +125,8 @@ The database normally lives at:
`OPENCODE_DB` can override the database location. `OPENCODE_DB` can override the database location.
<Warning> <Warning>
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon, Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
and make a backup before inspecting persistent data with external tools. daemon, and make a backup before inspecting persistent data with external tools.
</Warning> </Warning>
## Explicit servers ## Explicit servers