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
124 changes: 93 additions & 31 deletions docs/design/service-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,50 @@ This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery
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

| Area | State |
Expand Down Expand Up @@ -164,19 +208,22 @@ This design gives each concept one authority.

## System Model

```mermaid
flowchart LR
TUI[Fresh or existing TUI]
REG[Registration file]
LOCK[Process-held OS service lock]
SHELL[Lifecycle shell]
APP[OpenCode application]

TUI -->|discover| REG
TUI -->|observe| SHELL
TUI -->|normal requests| APP
SHELL --> APP
LOCK -->|authorizes one owner| SHELL
```text
╭───────────────────────╮ ╭──────────────────────────────╮
│ Fresh or existing TUI │ │ Process-held OS service lock │
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
╰─────┬ normal requests observe ───────────────────────╮ │
│ discover │ ├──╯ authorizes one owner
▼ │ ▼
╭───────────────────╮ │ ╭─────────────────╮
│ Registration file │ │ │ Lifecycle shell │
╰───────────────────╯ │ ╰────────┬────────╯
│ │
├────────────────────────╯
╭──────────────────────╮
│ OpenCode application │
╰──────────────────────╯
```

The lifecycle shell and application run in the same process. The distinction is
Expand Down Expand Up @@ -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
the foundation of the design, so the delivery sequence spikes it first.

```mermaid
flowchart TD
A[Contender process starts]
B{Acquire service lock?}
C[Exit successfully]
D[Bind lifecycle shell]
E[Write registration]
F[Report starting]
G[Initialize application]
H[Report ready]
I[Serve until shutdown]

A --> B
B -->|No| C
B -->|Yes| D
D --> E --> F --> G
G -->|Success| H --> I
G -->|Failure| J[Report failed and stay bound]
```text
Contender Lock Lifecycle Application
│ │ │ │
├─ try acquire ───▶ │ │
│ │ │ │
╭─ alt: lock held ────────────────────────────────────────────────╮
│ │ │ │ │ │
│ ◀─ busy ──────────┤ │ │ │
│ │ │ │ │ │
│ ├─────────╮ │ │ │ │
│ │ exit │ │ │ │ │
│ ◀─────────╯ │ │ │ │
│ │ │ │ │ │
├─ else: lock acquired ───────────────────────────────────────────┤
│ │ │ │ │ │
│ ◀─ owner ─────────┤ │ │ │
│ │ │ │ │ │
│ ├─ 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
Expand Down
10 changes: 3 additions & 7 deletions packages/cli/src/commands/handlers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
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"])

Expand All @@ -18,7 +18,7 @@ type OpenApi = {
export default Runtime.handler(
Commands.commands.api,
Effect.fn("cli.api")(function* (input) {
const server = yield* Server.resolve({
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
mismatch: "ignore",
Expand Down Expand Up @@ -62,11 +62,7 @@ export function rawRequest(input: readonly string[]) {
return { method: input[0].toUpperCase(), path: input[1] }
}

function resolveRequest(
endpoint: Service.Endpoint,
input: readonly string[],
params: Record<string, string>,
) {
function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) {
const raw = rawRequest(input)
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"))
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/src/commands/handlers/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
Expand All @@ -18,7 +18,7 @@ export default Runtime.handler(Commands, (input) =>
yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* Server.resolve({
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
onStart: (reason, existing) => {
Expand All @@ -37,11 +37,22 @@ export default Runtime.handler(Commands, (input) =>
preflight.loading()
const config = yield* Config.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 runPromise = Effect.runPromiseWith(context)
const service = server.service
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) },
config: {
path: config.path,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/handlers/mini.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
import { ServerConnection } from "../../services/server-connection"

export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
yield* Effect.promise(async () => validateMiniTerminal())
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(() =>
runMini({
server,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/handlers/run.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
import { ServerConnection } from "../../services/server-connection"

export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const separator = process.argv.indexOf("--", 2)
const server = yield* Server.resolve({
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
Expand Down
9 changes: 3 additions & 6 deletions packages/cli/src/mini/mini.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Service } from "@opencode-ai/client/effect"
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 { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"

export type MiniCommandInput = {
server: Server.Resolved
server: ServerConnection.Resolved
continue?: boolean
session?: string
fork?: boolean
Expand Down Expand Up @@ -38,10 +38,7 @@ export async function runMini(input: MiniCommandInput) {
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([
resolveAgent(),
selectSession(sdk, directory, input),
])
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/mini/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises"
import path from "node:path"
import { Server } from "../services/server"
import { ServerConnection } from "../services/server-connection"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import type { MiniToolPart } from "./types"
import { UI } from "./ui"

export type RunCommandInput = {
server: Server.Resolved
server: ServerConnection.Resolved
message: string[]
continue?: boolean
session?: string
Expand Down Expand Up @@ -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))
: undefined
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (variant && !model)
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
Expand Down
Loading
Loading