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
6 changes: 2 additions & 4 deletions packages/opencode/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { Instance } from "../project/instance"
import { InstanceStore } from "../project/instance-store"
import { getBootstrapRunEffect } from "../effect/app-runtime"
import { InstanceRuntime } from "../project/instance-runtime"

export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
directory,
init: await getBootstrapRunEffect(),
fn: async () => {
try {
const result = await cb()
return result
} finally {
await InstanceStore.disposeInstance(Instance.current)
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
Expand Down
17 changes: 12 additions & 5 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"

ensureProcessMetadata("worker")

Expand Down Expand Up @@ -77,19 +79,24 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
init: await getBootstrapRunEffect(),
fn: async () => {
await upgrade().catch(() => {})
},
})
},
async reload() {
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
await AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
yield* cfg.invalidate()
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
}),
)
},
async shutdown() {
Log.Default.info("worker shutting down")

await InstanceStore.disposeAllInstances()
await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true)
},
}
Expand Down
36 changes: 6 additions & 30 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { type InstanceContext } from "../project/instance"
import { InstanceStore } from "../project/instance-store"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
Expand Down Expand Up @@ -289,9 +286,9 @@ export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }>
readonly invalidate: () => Effect.Effect<void>
readonly directories: () => Effect.Effect<string[]>
readonly waitForDependencies: () => Effect.Effect<void>
}
Expand Down Expand Up @@ -730,37 +727,17 @@ export const layer = Layer.effect(
)
})

const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
const update = Effect.fn("Config.update")(function* (config: Info) {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
if (options?.dispose !== false) {
// Fail loudly if no instance is bound — silently skipping would
// mask "config update without an active instance" bugs. The throw
// comes from `Instance.current` inside `InstanceState.context`.
const ctx = yield* InstanceState.context
yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
}
})

const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
const invalidate = Effect.fn("Config.invalidate")(function* () {
yield* invalidateGlobal
const task = InstanceStore.disposeAllInstances()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
if (wait) yield* Effect.promise(() => task)
else void task
})

const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
Expand All @@ -784,9 +761,8 @@ export const layer = Layer.effect(
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}

// Only tear down running instances if the config actually changed.
if (changed) yield* invalidate()
return next
return { info: next, changed }
})

return Service.of({
Expand Down
20 changes: 3 additions & 17 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Layer, ManagedRuntime } from "effect"
import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"

Expand Down Expand Up @@ -40,8 +40,7 @@ import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
Expand Down Expand Up @@ -94,8 +93,7 @@ export const AppLayer = Layer.mergeAll(
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
InstanceRuntime.layer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Expand Down Expand Up @@ -132,15 +130,3 @@ export const AppRuntime: Runtime = {
},
dispose: () => rt.dispose(),
}

let bootstrapRun: Promise<Effect.Effect<void>>
export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
if (!bootstrapRun) {
bootstrapRun = AppRuntime.runPromise(
Effect.gen(function* () {
return (yield* InstanceBootstrap.Service).run
}),
)
}
return bootstrapRun
}
9 changes: 9 additions & 0 deletions packages/opencode/src/project/bootstrap-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Context, Effect } from "effect"

export interface Interface {
readonly run: Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}

export * as InstanceBootstrap from "./bootstrap-service"
14 changes: 6 additions & 8 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,19 @@ import { Command } from "../command"
import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
import { Context, Effect, Layer } from "effect"
import { Effect, Layer } from "effect"
import { Config } from "@/config/config"
import { Service } from "./bootstrap-service"

export interface Interface {
readonly run: Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
export { Service } from "./bootstrap-service"
export type { Interface } from "./bootstrap-service"

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// Yield each bootstrap dep at layer init so `run` itself has R = never.
// This breaks the circular declaration loop through Config → Instance → InstanceStore
// (instance-store.ts only yields this Service tag, never the impl-side services).
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
// so it can depend on bootstrap without importing this implementation graph.
const bus = yield* Bus.Service
const config = yield* Config.Service
const file = yield* File.Service
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/src/project/instance-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { makeRuntime } from "@/effect/run-service"
import { type InstanceContext } from "./instance-context"
import { InstanceStore, type LoadInput } from "./instance-store"
import { Effect, Layer } from "effect"

// Production InstanceStore wiring plus a bridge for Promise/ALS callers that
// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself
// low-level while still giving legacy Hono and CLI paths the production
// bootstrap implementation. Delete the Promise helpers once those callers are
// migrated to Effect boundaries that provide InstanceStore directly.
// Keep the bootstrap implementation import lazy: Instance is imported broadly,
// and importing the app bootstrap graph at module load can trigger ESM cycles.
export const layer = Layer.unwrap(
Effect.promise(async () => {
const { InstanceBootstrap } = await import("./bootstrap")
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
}),
)

const runtime = makeRuntime(InstanceStore.Service, layer)

export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input))
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))

export * as InstanceRuntime from "./instance-runtime"
15 changes: 4 additions & 11 deletions packages/opencode/src/project/instance-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { GlobalBus } from "@/bus/global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { type InstanceContext } from "./instance-context"
import { InstanceBootstrap } from "./bootstrap-service"
import * as Project from "./project"

export interface LoadInput<R = never> {
Expand Down Expand Up @@ -36,10 +36,11 @@ interface Entry {
readonly deferred: Deferred.Deferred<InstanceContext>
}

export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const bootstrap = yield* InstanceBootstrap.Service
const scope = yield* Scope.Scope
const cache = new Map<string, Entry>()

Expand All @@ -59,6 +60,7 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
project: result.project,
})),
)
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
return ctx
}).pipe(Effect.withSpan("InstanceStore.boot"))
Expand Down Expand Up @@ -195,13 +197,4 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(

export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))

export const runtime = makeRuntime(Service, defaultLayer)

// Promise-returning helpers for callers without an Effect runtime in scope.
// They route through `runtime` (not a yielded Service from a fresh runtime)
// so they share the cache that `Instance.provide` populates.
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))

export * as InstanceStore from "./instance-store"
6 changes: 2 additions & 4 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { Effect } from "effect"
import { context, type InstanceContext } from "./instance-context"
import { InstanceStore } from "./instance-store"
import { InstanceRuntime } from "./instance-runtime"

export type { InstanceContext } from "./instance-context"
export type { LoadInput } from "./instance-store"

export const Instance = {
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
const ctx = await InstanceStore.runtime.runPromise((store) =>
store.load({ directory: input.directory, init: input.init }),
)
const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init })
return context.provide(ctx, async () => input.fn())
},
get current() {
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/server/global-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { GlobalBus } from "@/bus/global"
import { InstanceStore } from "@/project/instance-store"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { Event } from "./event"

const log = Log.create({ service: "server" })

export const emitGlobalDisposed = Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)

export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn(
"Server.disposeAllInstancesAndEmitGlobalDisposed",
)(function* (options?: { swallowErrors?: boolean }) {
const store = yield* InstanceStore.Service
yield* Effect.gen(function* () {
yield* (options?.swallowErrors
? store.disposeAll().pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("global disposal failed", { cause })
}),
),
)
: store.disposeAll())
yield* emitGlobalDisposed
}).pipe(Effect.uninterruptible)
})

export * as GlobalLifecycle from "./global-lifecycle"
Loading
Loading