From f5398e7e1e8993644f8dd9f33fdbe0ff4a23ef75 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 08:20:35 -0400 Subject: [PATCH 1/8] refactor: move instance loading into service --- packages/opencode/src/project/instance.ts | 260 ++++++++++-------- .../httpapi/middleware/instance-context.ts | 33 ++- .../server/routes/instance/httpapi/server.ts | 2 + .../opencode/test/project/instance.test.ts | 84 ++++++ .../server/httpapi-instance-context.test.ts | 3 +- 5 files changed, 255 insertions(+), 127 deletions(-) create mode 100644 packages/opencode/test/project/instance.test.ts diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 623e886231ab..ac4243fa16b2 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -7,6 +7,7 @@ import * as Log from "@opencode-ai/core/util/log" import { LocalContext } from "@/util/local-context" import * as Project from "./project" import { WorkspaceContext } from "@/control-plane/workspace-context" +import { Context, Effect, Layer } from "effect" export interface InstanceContext { directory: string @@ -15,63 +16,160 @@ export interface InstanceContext { } const context = LocalContext.create("instance") -const cache = new Map>() -const project = makeRuntime(Project.Service, Project.defaultLayer) -const disposal = { - all: undefined as Promise | undefined, +export interface LoadInput { + directory: string + init?: () => Promise + worktree?: string + project?: Project.Info } -function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await project - .runPromise((svc) => svc.fromDirectory(input.directory)) - .then(({ project, sandbox }) => ({ +export interface Store { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect +} + +export class InstanceStore extends Context.Service()("@opencode/InstanceStore") {} + +export const instanceStoreLayer: Layer.Layer = Layer.effect( + InstanceStore, + Effect.gen(function* () { + const project = yield* Project.Service + const cache = new Map>() + const disposal = { + all: undefined as Promise | undefined, + } + + const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { + const ctx = + input.project && input.worktree + ? { directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + const init = input.init + if (init) yield* Effect.promise(() => context.provide(ctx, init)) + return ctx }) - return ctx - }) -} -function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task -} + function track(directory: string, next: Promise) { + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory) + throw error + }) + cache.set(directory, task) + return task + } + + const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + const existing = cache.get(directory) + if (existing) return yield* Effect.promise(() => existing) -export const Instance = { - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = AppFileSystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { Log.Default.info("creating instance", { directory }) - existing = track( + return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory })))) + }) + + const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + Log.Default.info("reloading instance", { directory }) + yield* Effect.promise(() => disposeInstance(directory)) + cache.delete(directory) + const next = track(directory, Effect.runPromise(boot({ ...input, directory }))) + + GlobalBus.emit("event", { directory, - boot({ - directory, - init: input.init, - }), - ) - } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() + project: input.project?.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) + + return yield* Effect.promise(() => next) + }) + + const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { + Log.Default.info("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => disposeInstance(ctx.directory)) + cache.delete(ctx.directory) + + GlobalBus.emit("event", { + directory: ctx.directory, + project: ctx.project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: ctx.directory, + }, + }, + }) + }) + + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + if (disposal.all) return yield* Effect.promise(() => disposal.all!) + + disposal.all = iife(async () => { + Log.Default.info("disposing all instances") + const entries = [...cache.entries()] + for (const [key, value] of entries) { + if (cache.get(key) !== value) continue + + const ctx = await value.catch((error) => { + Log.Default.warn("instance dispose failed", { key, error }) + return undefined + }) + + if (!ctx) { + if (cache.get(key) === value) cache.delete(key) + continue + } + + if (cache.get(key) !== value) continue + await Effect.runPromise(dispose(ctx)) + } + }).finally(() => { + disposal.all = undefined + }) + + return yield* Effect.promise(() => disposal.all!) }) + + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) + + return InstanceStore.of({ + load, + reload, + dispose, + disposeAll, + }) + }), +) + +export const instanceStoreDefaultLayer = instanceStoreLayer.pipe(Layer.provide(Project.defaultLayer)) + +const instanceStoreRuntime = makeRuntime(InstanceStore, instanceStoreDefaultLayer) + +export const Instance = { + load(input: LoadInput): Promise { + return instanceStoreRuntime.runPromise((store) => store.load(input)) + }, + async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + return context.provide(await Instance.load(input), async () => input.fn()) }, get current() { return context.use() @@ -117,74 +215,12 @@ export const Instance = { return context.provide(ctx, fn) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = AppFileSystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - - GlobalBus.emit("event", { - directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - - return await next + return instanceStoreRuntime.runPromise((store) => store.reload(input)) }, async dispose() { - const directory = Instance.directory - const project = Instance.project - Log.Default.info("disposing instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - - GlobalBus.emit("event", { - directory, - project: project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) + return instanceStoreRuntime.runPromise((store) => store.dispose(Instance.current)) }, async disposeAll() { - if (disposal.all) return disposal.all - - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue - - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) - - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } - - if (cache.get(key) !== value) continue - - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - }).finally(() => { - disposal.all = undefined - }) - - return disposal.all + return instanceStoreRuntime.runPromise((store) => store.disposeAll()) }, } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index c80f1caeb65d..81de3d739d04 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,8 +1,7 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" -import type { InstanceContext } from "@/project/instance" +import { InstanceStore, type InstanceContext, type Store } from "@/project/instance" import { Filesystem } from "@/util/filesystem" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -24,22 +23,20 @@ function decode(input: string): string { } } -function makeInstanceContext(directory: string): Effect.Effect { - return Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(directory)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) +function makeInstanceContext(store: Store, directory: string): Effect.Effect { + return store.load({ + directory: Filesystem.resolve(decode(directory)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + }) } function provideInstanceContext( effect: Effect.Effect, + store: Store, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - const ctx = yield* makeInstanceContext(route.directory) + const ctx = yield* makeInstanceContext(store, route.directory) return yield* effect.pipe( Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, route.workspaceID), @@ -47,9 +44,17 @@ function provideInstanceContext( }) } -export const instanceContextLayer = Layer.succeed( +export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, - InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), + Effect.gen(function* () { + const store = yield* InstanceStore + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) + }), ) -export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) +export const instanceRouterMiddleware = HttpRouter.middleware()( + Effect.gen(function* () { + const store = yield* InstanceStore + return (effect) => provideInstanceContext(effect, store) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e6dedfe2c4e8..18d33218e536 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -17,6 +17,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { instanceStoreDefaultLayer } from "@/project/instance" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" @@ -145,6 +146,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, + instanceStoreDefaultLayer, MCP.defaultLayer, Permission.defaultLayer, Project.defaultLayer, diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 000000000000..a909a138f18a --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" +import { Instance, InstanceStore, instanceStoreDefaultLayer } from "../../src/project/instance" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(instanceStoreDefaultLayer, CrossSpawnSpawner.defaultLayer)) + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("InstanceStore", () => { + it.live("loads instance context without installing ALS for the caller", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore + const ctx = yield* store.load({ directory: dir }) + + expect(ctx.directory).toBe(dir) + expect(ctx.worktree).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("runs load init inside the loaded legacy instance context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore + let initializedDirectory: string | undefined + + yield* store.load({ + directory: dir, + init: async () => { + initializedDirectory = Instance.directory + }, + }) + + expect(initializedDirectory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("caches loaded instance context by directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore + let initialized = 0 + + const first = yield* store.load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + const second = yield* store.load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + + expect(second).toBe(first) + expect(initialized).toBe(1) + }), + ) + + it.live("keeps Instance.provide as the legacy ALS wrapper", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + const directory = yield* Effect.promise(() => + Instance.provide({ + directory: dir, + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 9dea20dd6604..1e214d52e00f 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -11,7 +11,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { Instance } from "../../src/project/instance" +import { Instance, instanceStoreDefaultLayer } from "../../src/project/instance" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -40,6 +40,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + instanceStoreDefaultLayer, Project.defaultLayer, Workspace.defaultLayer, ), From f1470c1a889e2db14353cbca429184fe42d11e00 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 08:42:26 -0400 Subject: [PATCH 2/8] refactor: rename instance store service interface --- packages/opencode/src/project/instance.ts | 4 ++-- .../instance/httpapi/middleware/instance-context.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index ac4243fa16b2..05eb776db523 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -24,14 +24,14 @@ export interface LoadInput { project?: Project.Info } -export interface Store { +export interface Interface { readonly load: (input: LoadInput) => Effect.Effect readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect } -export class InstanceStore extends Context.Service()("@opencode/InstanceStore") {} +export class InstanceStore extends Context.Service()("@opencode/InstanceStore") {} export const instanceStoreLayer: Layer.Layer = Layer.effect( InstanceStore, diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 81de3d739d04..6bd37c63044c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,9 +1,9 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore, type InstanceContext, type Store } from "@/project/instance" +import { InstanceStore, type InstanceContext } from "@/project/instance" import { Filesystem } from "@/util/filesystem" -import { Effect, Layer } from "effect" +import { Context, Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import { WorkspaceRouteContext } from "./workspace-routing" @@ -23,7 +23,10 @@ function decode(input: string): string { } } -function makeInstanceContext(store: Store, directory: string): Effect.Effect { +function makeInstanceContext( + store: Context.Service.Shape, + directory: string, +): Effect.Effect { return store.load({ directory: Filesystem.resolve(decode(directory)), init: () => AppRuntime.runPromise(InstanceBootstrap), @@ -32,7 +35,7 @@ function makeInstanceContext(store: Store, directory: string): Effect.Effect( effect: Effect.Effect, - store: Store, + store: Context.Service.Shape, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext From f0136f947bb288fe59ed81185e28c26aca251f54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 08:52:36 -0400 Subject: [PATCH 3/8] fix: keep httpapi instance reloads in layer store --- .../opencode/src/project/instance-context.ts | 10 + .../opencode/src/project/instance-store.ts | 159 ++++++++++++++++ packages/opencode/src/project/instance.ts | 176 +----------------- .../routes/instance/httpapi/lifecycle.ts | 16 +- .../httpapi/middleware/instance-context.ts | 13 +- .../server/routes/instance/httpapi/server.ts | 4 +- .../opencode/test/project/instance.test.ts | 11 +- .../server/httpapi-instance-context.test.ts | 5 +- 8 files changed, 205 insertions(+), 189 deletions(-) create mode 100644 packages/opencode/src/project/instance-context.ts create mode 100644 packages/opencode/src/project/instance-store.ts diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts new file mode 100644 index 000000000000..22ceb28b3343 --- /dev/null +++ b/packages/opencode/src/project/instance-context.ts @@ -0,0 +1,10 @@ +import { LocalContext } from "@/util/local-context" +import type * as Project from "./project" + +export interface InstanceContext { + directory: string + worktree: string + project: Project.Info +} + +export const context = LocalContext.create("instance") diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts new file mode 100644 index 000000000000..a1bdeffe0552 --- /dev/null +++ b/packages/opencode/src/project/instance-store.ts @@ -0,0 +1,159 @@ +import { GlobalBus } from "@/bus/global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { disposeInstance } from "@/effect/instance-registry" +import { makeRuntime } from "@/effect/run-service" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import * as Log from "@opencode-ai/core/util/log" +import { Context, Effect, Layer } from "effect" +import { iife } from "@/util/iife" +import { context, type InstanceContext } from "./instance-context" +import * as Project from "./project" + +export interface LoadInput { + directory: string + init?: () => Promise + worktree?: string + project?: Project.Info +} + +export interface Interface { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceStore") {} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const cache = new Map>() + const disposal = { + all: undefined as Promise | undefined, + } + + const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + const init = input.init + if (init) yield* Effect.promise(() => context.provide(ctx, init)) + return ctx + }) + + function track(directory: string, next: Promise) { + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory) + throw error + }) + cache.set(directory, task) + return task + } + + const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + const existing = cache.get(directory) + if (existing) return yield* Effect.promise(() => existing) + + Log.Default.info("creating instance", { directory }) + return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory })))) + }) + + const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + Log.Default.info("reloading instance", { directory }) + yield* Effect.promise(() => disposeInstance(directory)) + cache.delete(directory) + const next = track(directory, Effect.runPromise(boot({ ...input, directory }))) + + GlobalBus.emit("event", { + directory, + project: input.project?.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) + + return yield* Effect.promise(() => next) + }) + + const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { + Log.Default.info("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => disposeInstance(ctx.directory)) + cache.delete(ctx.directory) + + GlobalBus.emit("event", { + directory: ctx.directory, + project: ctx.project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: ctx.directory, + }, + }, + }) + }) + + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + if (disposal.all) return yield* Effect.promise(() => disposal.all!) + + disposal.all = iife(async () => { + Log.Default.info("disposing all instances") + const entries = [...cache.entries()] + for (const [key, value] of entries) { + if (cache.get(key) !== value) continue + + const ctx = await value.catch((error) => { + Log.Default.warn("instance dispose failed", { key, error }) + return undefined + }) + + if (!ctx) { + if (cache.get(key) === value) cache.delete(key) + continue + } + + if (cache.get(key) !== value) continue + await Effect.runPromise(dispose(ctx)) + } + }).finally(() => { + disposal.all = undefined + }) + + return yield* Effect.promise(() => disposal.all!) + }) + + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) + + return Service.of({ + load, + reload, + dispose, + disposeAll, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) + +export const runtime = makeRuntime(Service, defaultLayer) + +export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 05eb776db523..7d8fa409e588 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,172 +1,14 @@ -import { GlobalBus } from "@/bus/global" -import { disposeInstance } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { iife } from "@/util/iife" -import * as Log from "@opencode-ai/core/util/log" -import { LocalContext } from "@/util/local-context" import * as Project from "./project" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { Context, Effect, Layer } from "effect" +import { context, type InstanceContext } from "./instance-context" +import { InstanceStore } from "./instance-store" -export interface InstanceContext { - directory: string - worktree: string - project: Project.Info -} - -const context = LocalContext.create("instance") - -export interface LoadInput { - directory: string - init?: () => Promise - worktree?: string - project?: Project.Info -} - -export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect - readonly dispose: (ctx: InstanceContext) => Effect.Effect - readonly disposeAll: () => Effect.Effect -} - -export class InstanceStore extends Context.Service()("@opencode/InstanceStore") {} - -export const instanceStoreLayer: Layer.Layer = Layer.effect( - InstanceStore, - Effect.gen(function* () { - const project = yield* Project.Service - const cache = new Map>() - const disposal = { - all: undefined as Promise | undefined, - } - - const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : yield* project.fromDirectory(input.directory).pipe( - Effect.map((result) => ({ - directory: input.directory, - worktree: result.sandbox, - project: result.project, - })), - ) - const init = input.init - if (init) yield* Effect.promise(() => context.provide(ctx, init)) - return ctx - }) - - function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task - } - - const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { - const directory = AppFileSystem.resolve(input.directory) - const existing = cache.get(directory) - if (existing) return yield* Effect.promise(() => existing) - - Log.Default.info("creating instance", { directory }) - return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory })))) - }) - - const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { - const directory = AppFileSystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - yield* Effect.promise(() => disposeInstance(directory)) - cache.delete(directory) - const next = track(directory, Effect.runPromise(boot({ ...input, directory }))) - - GlobalBus.emit("event", { - directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - - return yield* Effect.promise(() => next) - }) - - const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { - Log.Default.info("disposing instance", { directory: ctx.directory }) - yield* Effect.promise(() => disposeInstance(ctx.directory)) - cache.delete(ctx.directory) - - GlobalBus.emit("event", { - directory: ctx.directory, - project: ctx.project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory: ctx.directory, - }, - }, - }) - }) - - const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { - if (disposal.all) return yield* Effect.promise(() => disposal.all!) - - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue - - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) - - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } - - if (cache.get(key) !== value) continue - await Effect.runPromise(dispose(ctx)) - } - }).finally(() => { - disposal.all = undefined - }) - - return yield* Effect.promise(() => disposal.all!) - }) - - yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) - - return InstanceStore.of({ - load, - reload, - dispose, - disposeAll, - }) - }), -) - -export const instanceStoreDefaultLayer = instanceStoreLayer.pipe(Layer.provide(Project.defaultLayer)) - -const instanceStoreRuntime = makeRuntime(InstanceStore, instanceStoreDefaultLayer) +export type { InstanceContext } from "./instance-context" +export type { LoadInput } from "./instance-store" export const Instance = { - load(input: LoadInput): Promise { - return instanceStoreRuntime.runPromise((store) => store.load(input)) + load(input: InstanceStore.LoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(input)) }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { return context.provide(await Instance.load(input), async () => input.fn()) @@ -215,12 +57,12 @@ export const Instance = { return context.provide(ctx, fn) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - return instanceStoreRuntime.runPromise((store) => store.reload(input)) + return InstanceStore.runtime.runPromise((store) => store.reload(input)) }, async dispose() { - return instanceStoreRuntime.runPromise((store) => store.dispose(Instance.current)) + return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) }, async disposeAll() { - return instanceStoreRuntime.runPromise((store) => store.disposeAll()) + return InstanceStore.runtime.runPromise((store) => store.disposeAll()) }, } diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 7b263980c554..2f6c2fc8b5b2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -2,11 +2,13 @@ import type { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceRef } from "@/effect/instance-ref" import { Instance, type InstanceContext } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" type MarkedInstance = { ctx: InstanceContext + store: InstanceStore.Interface workspaceID?: WorkspaceID } @@ -17,17 +19,17 @@ const disposeAfterResponse = new WeakMap() const mark = (ctx: InstanceContext) => Effect.gen(function* () { - return { ctx, workspaceID: yield* WorkspaceRef } + return { ctx, store: yield* InstanceStore.Service, workspaceID: yield* WorkspaceRef } }) -// Instance.dispose/reload still publish events through legacy ALS helpers. +// InstanceStore lifecycle operations still publish events through legacy ALS helpers. // Effect request handlers carry these values in services, so bridge them back // into the legacy contexts only around the lifecycle operation. -const restoreMarked = (marked: MarkedInstance, fn: () => A) => +const restoreMarked = (marked: MarkedInstance, effect: Effect.Effect) => Effect.promise(() => WorkspaceContext.provide({ workspaceID: marked.workspaceID, - fn: () => Instance.restore(marked.ctx, fn), + fn: () => Instance.restore(marked.ctx, () => Effect.runPromise(effect)), }), ) @@ -43,11 +45,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) => ) }) -export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => +export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) => Effect.gen(function* () { const marked = yield* mark(ctx) return yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response), + Effect.as(Effect.uninterruptible(restoreMarked(marked, marked.store.reload(next))), response), ) }) @@ -58,6 +60,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => const marked = disposeAfterResponse.get(request.source) if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose())) + yield* Effect.uninterruptible(restoreMarked(marked, marked.store.dispose(marked.ctx))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 6bd37c63044c..d616e6a31d09 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,9 +1,10 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore, type InstanceContext } from "@/project/instance" +import type { InstanceContext } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Filesystem } from "@/util/filesystem" -import { Context, Effect, Layer } from "effect" +import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import { WorkspaceRouteContext } from "./workspace-routing" @@ -24,7 +25,7 @@ function decode(input: string): string { } function makeInstanceContext( - store: Context.Service.Shape, + store: InstanceStore.Interface, directory: string, ): Effect.Effect { return store.load({ @@ -35,7 +36,7 @@ function makeInstanceContext( function provideInstanceContext( effect: Effect.Effect, - store: Context.Service.Shape, + store: InstanceStore.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext @@ -50,14 +51,14 @@ function provideInstanceContext( export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, Effect.gen(function* () { - const store = yield* InstanceStore + const store = yield* InstanceStore.Service return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) }), ) export const instanceRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { - const store = yield* InstanceStore + const store = yield* InstanceStore.Service return (effect) => provideInstanceContext(effect, store) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 18d33218e536..783f84ec82e9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -17,7 +17,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" -import { instanceStoreDefaultLayer } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" @@ -146,7 +146,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, - instanceStoreDefaultLayer, + InstanceStore.defaultLayer, MCP.defaultLayer, Permission.defaultLayer, Project.defaultLayer, diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index a909a138f18a..29d93555fec1 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" -import { Instance, InstanceStore, instanceStoreDefaultLayer } from "../../src/project/instance" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(instanceStoreDefaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) afterEach(async () => { await Instance.disposeAll() @@ -15,7 +16,7 @@ describe("InstanceStore", () => { it.live("loads instance context without installing ALS for the caller", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) - const store = yield* InstanceStore + const store = yield* InstanceStore.Service const ctx = yield* store.load({ directory: dir }) expect(ctx.directory).toBe(dir) @@ -27,7 +28,7 @@ describe("InstanceStore", () => { it.live("runs load init inside the loaded legacy instance context", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) - const store = yield* InstanceStore + const store = yield* InstanceStore.Service let initializedDirectory: string | undefined yield* store.load({ @@ -45,7 +46,7 @@ describe("InstanceStore", () => { it.live("caches loaded instance context by directory", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) - const store = yield* InstanceStore + const store = yield* InstanceStore.Service let initialized = 0 const first = yield* store.load({ diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 1e214d52e00f..6098ad9aaf7f 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -11,7 +11,8 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { Instance, instanceStoreDefaultLayer } from "../../src/project/instance" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -40,7 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - instanceStoreDefaultLayer, + InstanceStore.defaultLayer, Project.defaultLayer, Workspace.defaultLayer, ), From c565bd54e2748758c97d44354dd82c671f111b9a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 10:36:50 -0400 Subject: [PATCH 4/8] refactor: simplify instance store wiring --- packages/opencode/src/project/instance.ts | 5 +++- .../instance/httpapi/handlers/global.ts | 5 ++-- .../routes/instance/httpapi/lifecycle.ts | 25 +++++-------------- .../httpapi/middleware/instance-context.ts | 3 +-- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 7d8fa409e588..69cb74fd6da8 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -11,7 +11,10 @@ export const Instance = { return InstanceStore.runtime.runPromise((store) => store.load(input)) }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide(await Instance.load(input), async () => input.fn()) + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), + ) }, get current() { return context.use() diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index cd1bebec47a1..bcad2832e2e5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { Installation } from "@/installation" -import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -68,6 +68,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service + const store = yield* InstanceStore.Service const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -86,7 +87,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) + yield* store.disposeAll() GlobalBus.emit("event", { directory: "global", payload: { type: "global.disposed", properties: {} }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 2f6c2fc8b5b2..53d54e2a81e0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,7 +1,5 @@ -import type { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { WorkspaceRef } from "@/effect/instance-ref" -import { Instance, type InstanceContext } from "@/project/instance" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance" import { InstanceStore } from "@/project/instance-store" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" @@ -9,7 +7,7 @@ import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/h type MarkedInstance = { ctx: InstanceContext store: InstanceStore.Interface - workspaceID?: WorkspaceID + bridge: EffectBridge.Shape } // Disposal is requested by an endpoint handler, but must run from the outer @@ -19,20 +17,9 @@ const disposeAfterResponse = new WeakMap() const mark = (ctx: InstanceContext) => Effect.gen(function* () { - return { ctx, store: yield* InstanceStore.Service, workspaceID: yield* WorkspaceRef } + return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() } }) -// InstanceStore lifecycle operations still publish events through legacy ALS helpers. -// Effect request handlers carry these values in services, so bridge them back -// into the legacy contexts only around the lifecycle operation. -const restoreMarked = (marked: MarkedInstance, effect: Effect.Effect) => - Effect.promise(() => - WorkspaceContext.provide({ - workspaceID: marked.workspaceID, - fn: () => Instance.restore(marked.ctx, () => Effect.runPromise(effect)), - }), - ) - export const markInstanceForDisposal = (ctx: InstanceContext) => Effect.gen(function* () { const marked = yield* mark(ctx) @@ -49,7 +36,7 @@ export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore. Effect.gen(function* () { const marked = yield* mark(ctx) return yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as(Effect.uninterruptible(restoreMarked(marked, marked.store.reload(next))), response), + Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response), ) }) @@ -60,6 +47,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => const marked = disposeAfterResponse.get(request.source) if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(restoreMarked(marked, marked.store.dispose(marked.ctx))) + yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index d616e6a31d09..1d7d84cbc065 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -3,7 +3,6 @@ import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" import type { InstanceContext } from "@/project/instance" import { InstanceStore } from "@/project/instance-store" -import { Filesystem } from "@/util/filesystem" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -29,7 +28,7 @@ function makeInstanceContext( directory: string, ): Effect.Effect { return store.load({ - directory: Filesystem.resolve(decode(directory)), + directory: decode(directory), init: () => AppRuntime.runPromise(InstanceBootstrap), }) } From 8a63cbe79c28b8f2b63e9d236afc5e586fec364c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 12:04:13 -0400 Subject: [PATCH 5/8] refactor: simplify instance store concurrency --- .../opencode/src/project/instance-store.ts | 190 +++++++++++------- .../opencode/test/project/instance.test.ts | 150 +++++++++++++- 2 files changed, 264 insertions(+), 76 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index a1bdeffe0552..e7c0f657c175 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -3,9 +3,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context" import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as Log from "@opencode-ai/core/util/log" -import { Context, Effect, Layer } from "effect" -import { iife } from "@/util/iife" +import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" import { context, type InstanceContext } from "./instance-context" import * as Project from "./project" @@ -25,13 +23,18 @@ export interface Interface { export class Service extends Context.Service()("@opencode/InstanceStore") {} +interface Entry { + readonly deferred: Deferred.Deferred +} + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service - const cache = new Map>() + const scope = yield* Scope.Scope + const cache = new Map() const disposal = { - all: undefined as Promise | undefined, + all: undefined as Deferred.Deferred | undefined, } const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { @@ -54,91 +57,128 @@ export const layer: Layer.Layer = Layer.effect( return ctx }) - function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error + const removeEntry = (directory: string, entry: Entry) => + Effect.sync(() => { + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true }) - cache.set(directory, task) - return task - } + + const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) + + const emitDisposed = (input: { directory: string; project?: string }) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: input.directory, + project: input.project, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: input.directory, + }, + }, + }), + ) + + const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { + yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => disposeInstance(ctx.directory)) + yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) + }) + + const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) { + if (cache.get(directory) !== entry) return false + yield* disposeContext(ctx) + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { const directory = AppFileSystem.resolve(input.directory) - const existing = cache.get(directory) - if (existing) return yield* Effect.promise(() => existing) - - Log.Default.info("creating instance", { directory }) - return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory })))) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = cache.get(directory) + if (existing) return yield* restore(Deferred.await(existing.deferred)) + + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("creating instance", { directory }) + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ) }) const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { const directory = AppFileSystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - yield* Effect.promise(() => disposeInstance(directory)) - cache.delete(directory) - const next = track(directory, Effect.runPromise(boot({ ...input, directory }))) - - GlobalBus.emit("event", { - directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - - return yield* Effect.promise(() => next) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const previous = cache.get(directory) + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("reloading instance", { directory }) + if (previous) yield* Deferred.await(previous.deferred).pipe(Effect.exit, Effect.asVoid) + yield* Effect.promise(() => disposeInstance(directory)) + yield* emitDisposed({ directory, project: input.project?.id }) + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ) }) const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { - Log.Default.info("disposing instance", { directory: ctx.directory }) - yield* Effect.promise(() => disposeInstance(ctx.directory)) - cache.delete(ctx.directory) - - GlobalBus.emit("event", { - directory: ctx.directory, - project: ctx.project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory: ctx.directory, - }, - }, - }) + const entry = cache.get(ctx.directory) + if (!entry) return yield* disposeContext(ctx) + + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid) + if (exit.value !== ctx) return + yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid) }) const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { - if (disposal.all) return yield* Effect.promise(() => disposal.all!) - - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue - - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) - - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = disposal.all + if (existing) return yield* restore(Deferred.await(existing)) + + const done = Deferred.makeUnsafe() + const entries = [...cache.entries()] + disposal.all = done + const exit = yield* Effect.gen(function* () { + yield* Effect.logInfo("disposing all instances") + yield* Effect.forEach( + entries, + (item) => + Effect.gen(function* () { + const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) + yield* removeEntry(item[0], item[1]) + return + } + yield* disposeEntry(item[0], item[1], exit.value) + }), + { discard: true }, + ) + }).pipe(Effect.exit) + yield* Deferred.done(done, exit).pipe(Effect.asVoid) + if (disposal.all === done) { + disposal.all = undefined } - - if (cache.get(key) !== value) continue - await Effect.runPromise(dispose(ctx)) - } - }).finally(() => { - disposal.all = undefined - }) - - return yield* Effect.promise(() => disposal.all!) + return yield* restore(Deferred.await(done)) + }), + ) }) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 29d93555fec1..064c23383c82 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer } from "effect" +import { registerDisposer } from "../../src/effect/instance-registry" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { tmpdirScoped } from "../fixture/fixture" @@ -67,6 +68,153 @@ describe("InstanceStore", () => { }), ) + it.live("dedupes concurrent loads while init is in flight", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let initialized = 0 + + const first = yield* store + .load({ + directory: dir, + init: async () => { + initialized++ + started.resolve() + await release.promise + }, + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => started.promise) + + const second = yield* store + .load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + .pipe(Effect.forkScoped) + + expect(initialized).toBe(1) + release.resolve() + + const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(secondCtx).toBe(firstCtx) + expect(initialized).toBe(1) + }), + ) + + it.live("removes failed loads from the cache", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let attempts = 0 + + const failed = yield* store + .load({ + directory: dir, + init: async () => { + attempts++ + throw new Error("init failed") + }, + }) + .pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) + + expect(failed).toBe(true) + + const ctx = yield* store.load({ + directory: dir, + init: async () => { + attempts++ + }, + }) + + expect(ctx.directory).toBe(dir) + expect(attempts).toBe(2) + }), + ) + + it.live("reload replaces the cached context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + + const first = yield* store.load({ directory: dir }) + const second = yield* store.reload({ directory: dir }) + const cached = yield* store.load({ directory: dir }) + + expect(second).not.toBe(first) + expect(cached).toBe(second) + }), + ) + + it.live("stale dispose does not delete an in-flight reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const reloading = Promise.withResolvers() + const releaseReload = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const first = yield* store.load({ directory: dir }) + const reload = yield* store + .reload({ + directory: dir, + init: async () => { + reloading.resolve() + await releaseReload.promise + }, + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => reloading.promise) + const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) + releaseReload.resolve() + + const second = yield* Fiber.join(reload) + yield* Fiber.join(staleDispose) + + expect(disposed).toEqual([dir]) + expect(yield* store.load({ directory: dir })).toBe(second) + }), + ) + + it.live("dedupes concurrent disposeAll calls", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposing = Promise.withResolvers() + const releaseDispose = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + disposing.resolve() + await releaseDispose.promise + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir }) + const first = yield* store.disposeAll().pipe(Effect.forkScoped) + yield* Effect.promise(() => disposing.promise) + const second = yield* store.disposeAll().pipe(Effect.forkScoped) + + expect(disposed).toEqual([dir]) + releaseDispose.resolve() + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(disposed).toEqual([dir]) + }), + ) + it.live("keeps Instance.provide as the legacy ALS wrapper", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From 1b146ad0948f77df7735d01d298de029a6e5f6e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:02:42 -0400 Subject: [PATCH 6/8] refactor: replace disposeAll dedup slot with cachedWithTTL The manual Deferred slot + uninterruptibleMask + identity check collapses into Effect.cachedWithTTL(_, Duration.zero): concurrent callers share the in-flight execution, and the cache expires on completion so the next call runs fresh. Adds a test pinning the re-arm semantic. --- .../opencode/src/project/instance-store.ts | 57 +++++++------------ .../opencode/test/project/instance.test.ts | 21 +++++++ 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index e7c0f657c175..3de0bdbc3683 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -3,7 +3,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context" import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" +import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { context, type InstanceContext } from "./instance-context" import * as Project from "./project" @@ -33,9 +33,6 @@ export const layer: Layer.Layer = Layer.effect( const project = yield* Project.Service const scope = yield* Scope.Scope const cache = new Map() - const disposal = { - all: undefined as Deferred.Deferred | undefined, - } const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { const ctx = @@ -146,41 +143,29 @@ export const layer: Layer.Layer = Layer.effect( yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid) }) - const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const existing = disposal.all - if (existing) return yield* restore(Deferred.await(existing)) - - const done = Deferred.makeUnsafe() - const entries = [...cache.entries()] - disposal.all = done - const exit = yield* Effect.gen(function* () { - yield* Effect.logInfo("disposing all instances") - yield* Effect.forEach( - entries, - (item) => - Effect.gen(function* () { - const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) - if (Exit.isFailure(exit)) { - yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) - yield* removeEntry(item[0], item[1]) - return - } - yield* disposeEntry(item[0], item[1], exit.value) - }), - { discard: true }, - ) - }).pipe(Effect.exit) - yield* Deferred.done(done, exit).pipe(Effect.asVoid) - if (disposal.all === done) { - disposal.all = undefined - } - return yield* restore(Deferred.await(done)) - }), + const disposeAllOnce = Effect.fnUntraced(function* () { + yield* Effect.logInfo("disposing all instances") + yield* Effect.forEach( + [...cache.entries()], + (item) => + Effect.gen(function* () { + const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) + yield* removeEntry(item[0], item[1]) + return + } + yield* disposeEntry(item[0], item[1], exit.value) + }), + { discard: true }, ) }) + const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero) + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + return yield* cachedDisposeAll + }) + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) return Service.of({ diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 064c23383c82..f9fb6dca4ed4 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -215,6 +215,27 @@ describe("InstanceStore", () => { }), ) + it.live("re-arms disposeAll after completion", () => + Effect.gen(function* () { + const dir1 = yield* tmpdirScoped({ git: true }) + const dir2 = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir1 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1]) + + yield* store.load({ directory: dir2 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1, dir2]) + }), + ) + it.live("keeps Instance.provide as the legacy ALS wrapper", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From 74373f85c7bd09a76e98416b79a56adaddb252b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:10:09 -0400 Subject: [PATCH 7/8] fix: skip reload disposal for fresh instances Previously reload always called disposeInstance + emitted server.instance.disposed even when no previous entry existed in the cache, sending a phantom dispose event for an instance that was never loaded. --- packages/opencode/src/project/instance-store.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 3de0bdbc3683..80f76d5cfa1d 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -123,9 +123,11 @@ export const layer: Layer.Layer = Layer.effect( cache.set(directory, entry) yield* Effect.gen(function* () { yield* Effect.logInfo("reloading instance", { directory }) - if (previous) yield* Deferred.await(previous.deferred).pipe(Effect.exit, Effect.asVoid) - yield* Effect.promise(() => disposeInstance(directory)) - yield* emitDisposed({ directory, project: input.project?.id }) + if (previous) { + yield* Deferred.await(previous.deferred).pipe(Effect.exit, Effect.asVoid) + yield* Effect.promise(() => disposeInstance(directory)) + yield* emitDisposed({ directory, project: input.project?.id }) + } yield* completeLoad(directory, input, entry) }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) From d9252a09dde1959861563d0f411c74d59528d3e8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:10:42 -0400 Subject: [PATCH 8/8] refactor: use Effect.ignore over exit + asVoid --- packages/opencode/src/project/instance-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 80f76d5cfa1d..327835ea074d 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -124,7 +124,7 @@ export const layer: Layer.Layer = Layer.effect( yield* Effect.gen(function* () { yield* Effect.logInfo("reloading instance", { directory }) if (previous) { - yield* Deferred.await(previous.deferred).pipe(Effect.exit, Effect.asVoid) + yield* Deferred.await(previous.deferred).pipe(Effect.ignore) yield* Effect.promise(() => disposeInstance(directory)) yield* emitDisposed({ directory, project: input.project?.id }) }