Skip to content

Commit f98053c

Browse files
authored
fix(instance): run bootstrap from instance store (#25475)
1 parent 36007ae commit f98053c

42 files changed

Lines changed: 540 additions & 249 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/opencode/src/cli/bootstrap.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import { Instance } from "../project/instance"
2-
import { InstanceStore } from "../project/instance-store"
3-
import { getBootstrapRunEffect } from "../effect/app-runtime"
2+
import { InstanceRuntime } from "../project/instance-runtime"
43

54
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
65
return Instance.provide({
76
directory,
8-
init: await getBootstrapRunEffect(),
97
fn: async () => {
108
try {
119
const result = await cb()
1210
return result
1311
} finally {
14-
await InstanceStore.disposeInstance(Instance.current)
12+
await InstanceRuntime.disposeInstance(Instance.current)
1513
}
1614
},
1715
})

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ import { Installation } from "@/installation"
22
import { Server } from "@/server/server"
33
import * as Log from "@opencode-ai/core/util/log"
44
import { Instance } from "@/project/instance"
5-
import { InstanceStore } from "@/project/instance-store"
5+
import { InstanceRuntime } from "@/project/instance-runtime"
66
import { Rpc } from "@/util/rpc"
77
import { upgrade } from "@/cli/upgrade"
88
import { Config } from "@/config/config"
99
import { GlobalBus } from "@/bus/global"
1010
import { Flag } from "@opencode-ai/core/flag/flag"
1111
import { writeHeapSnapshot } from "node:v8"
1212
import { Heap } from "@/cli/heap"
13-
import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
13+
import { AppRuntime } from "@/effect/app-runtime"
1414
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
15+
import { Effect } from "effect"
16+
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
1517

1618
ensureProcessMetadata("worker")
1719

@@ -77,19 +79,24 @@ export const rpc = {
7779
async checkUpgrade(input: { directory: string }) {
7880
await Instance.provide({
7981
directory: input.directory,
80-
init: await getBootstrapRunEffect(),
8182
fn: async () => {
8283
await upgrade().catch(() => {})
8384
},
8485
})
8586
},
8687
async reload() {
87-
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
88+
await AppRuntime.runPromise(
89+
Effect.gen(function* () {
90+
const cfg = yield* Config.Service
91+
yield* cfg.invalidate()
92+
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
93+
}),
94+
)
8895
},
8996
async shutdown() {
9097
Log.Default.info("worker shutting down")
9198

92-
await InstanceStore.disposeAllInstances()
99+
await InstanceRuntime.disposeAllInstances()
93100
if (server) await server.stop(true)
94101
},
95102
}

packages/opencode/src/config/config.ts

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@ import { Auth } from "../auth"
1212
import { Env } from "../env"
1313
import { applyEdits, modify } from "jsonc-parser"
1414
import { type InstanceContext } from "../project/instance"
15-
import { InstanceStore } from "../project/instance-store"
1615
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
1716
import { existsSync } from "fs"
18-
import { GlobalBus } from "@/bus/global"
19-
import { Event } from "../server/event"
2017
import { Account } from "@/account/account"
2118
import { isRecord } from "@/util/record"
2219
import type { ConsoleState } from "./console-state"
@@ -289,9 +286,9 @@ export interface Interface {
289286
readonly get: () => Effect.Effect<Info>
290287
readonly getGlobal: () => Effect.Effect<Info>
291288
readonly getConsoleState: () => Effect.Effect<ConsoleState>
292-
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
293-
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
294-
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
289+
readonly update: (config: Info) => Effect.Effect<void>
290+
readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }>
291+
readonly invalidate: () => Effect.Effect<void>
295292
readonly directories: () => Effect.Effect<string[]>
296293
readonly waitForDependencies: () => Effect.Effect<void>
297294
}
@@ -730,37 +727,17 @@ export const layer = Layer.effect(
730727
)
731728
})
732729

733-
const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
730+
const update = Effect.fn("Config.update")(function* (config: Info) {
734731
const dir = yield* InstanceState.directory
735732
const file = path.join(dir, "config.json")
736733
const existing = yield* loadFile(file)
737734
yield* fs
738735
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
739736
.pipe(Effect.orDie)
740-
if (options?.dispose !== false) {
741-
// Fail loudly if no instance is bound — silently skipping would
742-
// mask "config update without an active instance" bugs. The throw
743-
// comes from `Instance.current` inside `InstanceState.context`.
744-
const ctx = yield* InstanceState.context
745-
yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
746-
}
747737
})
748738

749-
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
739+
const invalidate = Effect.fn("Config.invalidate")(function* () {
750740
yield* invalidateGlobal
751-
const task = InstanceStore.disposeAllInstances()
752-
.catch(() => undefined)
753-
.finally(() =>
754-
GlobalBus.emit("event", {
755-
directory: "global",
756-
payload: {
757-
type: Event.Disposed.type,
758-
properties: {},
759-
},
760-
}),
761-
)
762-
if (wait) yield* Effect.promise(() => task)
763-
else void task
764741
})
765742

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

787-
// Only tear down running instances if the config actually changed.
788764
if (changed) yield* invalidate()
789-
return next
765+
return { info: next, changed }
790766
})
791767

792768
return Service.of({

packages/opencode/src/effect/app-runtime.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Effect, Layer, ManagedRuntime } from "effect"
1+
import { Layer, ManagedRuntime } from "effect"
22
import { attach } from "./run-service"
33
import * as Observability from "@opencode-ai/core/effect/observability"
44

@@ -40,8 +40,7 @@ import { Command } from "@/command"
4040
import { Truncate } from "@/tool/truncate"
4141
import { ToolRegistry } from "@/tool/registry"
4242
import { Format } from "@/format"
43-
import { InstanceBootstrap } from "@/project/bootstrap"
44-
import { InstanceStore } from "@/project/instance-store"
43+
import { InstanceRuntime } from "@/project/instance-runtime"
4544
import { Project } from "@/project/project"
4645
import { Vcs } from "@/project/vcs"
4746
import { Workspace } from "@/control-plane/workspace"
@@ -94,8 +93,7 @@ export const AppLayer = Layer.mergeAll(
9493
Truncate.defaultLayer,
9594
ToolRegistry.defaultLayer,
9695
Format.defaultLayer,
97-
InstanceBootstrap.defaultLayer,
98-
InstanceStore.defaultLayer,
96+
InstanceRuntime.layer,
9997
Project.defaultLayer,
10098
Vcs.defaultLayer,
10199
Workspace.defaultLayer,
@@ -132,15 +130,3 @@ export const AppRuntime: Runtime = {
132130
},
133131
dispose: () => rt.dispose(),
134132
}
135-
136-
let bootstrapRun: Promise<Effect.Effect<void>>
137-
export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
138-
if (!bootstrapRun) {
139-
bootstrapRun = AppRuntime.runPromise(
140-
Effect.gen(function* () {
141-
return (yield* InstanceBootstrap.Service).run
142-
}),
143-
)
144-
}
145-
return bootstrapRun
146-
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Context, Effect } from "effect"
2+
3+
export interface Interface {
4+
readonly run: Effect.Effect<void>
5+
}
6+
7+
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
8+
9+
export * as InstanceBootstrap from "./bootstrap-service"

packages/opencode/src/project/bootstrap.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,19 @@ import { Command } from "../command"
1010
import { InstanceState } from "@/effect/instance-state"
1111
import { FileWatcher } from "@/file/watcher"
1212
import { ShareNext } from "@/share/share-next"
13-
import { Context, Effect, Layer } from "effect"
13+
import { Effect, Layer } from "effect"
1414
import { Config } from "@/config/config"
15+
import { Service } from "./bootstrap-service"
1516

16-
export interface Interface {
17-
readonly run: Effect.Effect<void>
18-
}
19-
20-
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
17+
export { Service } from "./bootstrap-service"
18+
export type { Interface } from "./bootstrap-service"
2119

2220
export const layer = Layer.effect(
2321
Service,
2422
Effect.gen(function* () {
2523
// Yield each bootstrap dep at layer init so `run` itself has R = never.
26-
// This breaks the circular declaration loop through Config → Instance → InstanceStore
27-
// (instance-store.ts only yields this Service tag, never the impl-side services).
24+
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
25+
// so it can depend on bootstrap without importing this implementation graph.
2826
const bus = yield* Bus.Service
2927
const config = yield* Config.Service
3028
const file = yield* File.Service
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { makeRuntime } from "@/effect/run-service"
2+
import { type InstanceContext } from "./instance-context"
3+
import { InstanceStore, type LoadInput } from "./instance-store"
4+
import { Effect, Layer } from "effect"
5+
6+
// Production InstanceStore wiring plus a bridge for Promise/ALS callers that
7+
// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself
8+
// low-level while still giving legacy Hono and CLI paths the production
9+
// bootstrap implementation. Delete the Promise helpers once those callers are
10+
// migrated to Effect boundaries that provide InstanceStore directly.
11+
// Keep the bootstrap implementation import lazy: Instance is imported broadly,
12+
// and importing the app bootstrap graph at module load can trigger ESM cycles.
13+
export const layer = Layer.unwrap(
14+
Effect.promise(async () => {
15+
const { InstanceBootstrap } = await import("./bootstrap")
16+
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
17+
}),
18+
)
19+
20+
const runtime = makeRuntime(InstanceStore.Service, layer)
21+
22+
export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input))
23+
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
24+
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
25+
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
26+
27+
export * as InstanceRuntime from "./instance-runtime"

packages/opencode/src/project/instance-store.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { GlobalBus } from "@/bus/global"
22
import { WorkspaceContext } from "@/control-plane/workspace-context"
33
import { InstanceRef } from "@/effect/instance-ref"
44
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
5-
import { makeRuntime } from "@/effect/run-service"
65
import { AppFileSystem } from "@opencode-ai/core/filesystem"
76
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
87
import { type InstanceContext } from "./instance-context"
8+
import { InstanceBootstrap } from "./bootstrap-service"
99
import * as Project from "./project"
1010

1111
export interface LoadInput<R = never> {
@@ -36,10 +36,11 @@ interface Entry {
3636
readonly deferred: Deferred.Deferred<InstanceContext>
3737
}
3838

39-
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
39+
export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
4040
Service,
4141
Effect.gen(function* () {
4242
const project = yield* Project.Service
43+
const bootstrap = yield* InstanceBootstrap.Service
4344
const scope = yield* Scope.Scope
4445
const cache = new Map<string, Entry>()
4546

@@ -59,6 +60,7 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
5960
project: result.project,
6061
})),
6162
)
63+
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
6264
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
6365
return ctx
6466
}).pipe(Effect.withSpan("InstanceStore.boot"))
@@ -195,13 +197,4 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
195197

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

198-
export const runtime = makeRuntime(Service, defaultLayer)
199-
200-
// Promise-returning helpers for callers without an Effect runtime in scope.
201-
// They route through `runtime` (not a yielded Service from a fresh runtime)
202-
// so they share the cache that `Instance.provide` populates.
203-
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
204-
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
205-
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
206-
207200
export * as InstanceStore from "./instance-store"

packages/opencode/src/project/instance.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { Effect } from "effect"
22
import { context, type InstanceContext } from "./instance-context"
3-
import { InstanceStore } from "./instance-store"
3+
import { InstanceRuntime } from "./instance-runtime"
44

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

88
export const Instance = {
99
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
10-
const ctx = await InstanceStore.runtime.runPromise((store) =>
11-
store.load({ directory: input.directory, init: input.init }),
12-
)
10+
const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init })
1311
return context.provide(ctx, async () => input.fn())
1412
},
1513
get current() {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { GlobalBus } from "@/bus/global"
2+
import { InstanceStore } from "@/project/instance-store"
3+
import * as Log from "@opencode-ai/core/util/log"
4+
import { Effect } from "effect"
5+
import { Event } from "./event"
6+
7+
const log = Log.create({ service: "server" })
8+
9+
export const emitGlobalDisposed = Effect.sync(() =>
10+
GlobalBus.emit("event", {
11+
directory: "global",
12+
payload: {
13+
type: Event.Disposed.type,
14+
properties: {},
15+
},
16+
}),
17+
)
18+
19+
export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn(
20+
"Server.disposeAllInstancesAndEmitGlobalDisposed",
21+
)(function* (options?: { swallowErrors?: boolean }) {
22+
const store = yield* InstanceStore.Service
23+
yield* Effect.gen(function* () {
24+
yield* (options?.swallowErrors
25+
? store.disposeAll().pipe(
26+
Effect.catchCause((cause) =>
27+
Effect.sync(() => {
28+
log.warn("global disposal failed", { cause })
29+
}),
30+
),
31+
)
32+
: store.disposeAll())
33+
yield* emitGlobalDisposed
34+
}).pipe(Effect.uninterruptible)
35+
})
36+
37+
export * as GlobalLifecycle from "./global-lifecycle"

0 commit comments

Comments
 (0)