Normalize instance lifecycle wiring (#25501)

This commit is contained in:
Kit Langton
2026-05-02 20:39:20 -04:00
committed by GitHub
parent a6464062b7
commit 7d91d3b1ed
71 changed files with 852 additions and 936 deletions

View File

@@ -1,8 +1,9 @@
import { Instance } from "../project/instance"
import { InstanceRuntime } from "../project/instance-runtime"
import { WithInstance } from "../project/with-instance"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
return WithInstance.provide({
directory,
fn: async () => {
try {

View File

@@ -10,6 +10,7 @@ import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { WithInstance } from "../../project/with-instance"
import { EOL } from "os"
import type { Argv } from "yargs"
@@ -61,7 +62,7 @@ const AgentCreateCommand = cmd({
describe: "model to use in the format of provider/model",
}),
async handler(args) {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
const cliPath = args.path
@@ -236,7 +237,7 @@ const AgentListCommand = cmd({
command: "list",
describe: "list all available agents",
async handler() {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))

View File

@@ -20,6 +20,7 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "@/provider/models"
import { Instance } from "@/project/instance"
import { WithInstance } from "@/project/with-instance"
import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
@@ -203,7 +204,7 @@ export const GithubInstallCommand = cmd({
command: "install",
describe: "install the GitHub agent",
async handler() {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
{

View File

@@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "@/config/config"
import { ConfigMCP } from "../../config/mcp"
import { Instance } from "../../project/instance"
import { WithInstance } from "../../project/with-instance"
import { Installation } from "../../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "path"
@@ -114,7 +115,7 @@ export const McpListCommand = cmd({
aliases: ["ls"],
describe: "list MCP servers and their status",
async handler() {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
@@ -186,7 +187,7 @@ export const McpAuthCommand = cmd({
})
.command(McpAuthListCommand),
async handler(args) {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
@@ -318,7 +319,7 @@ export const McpAuthListCommand = cmd({
aliases: ["ls"],
describe: "list OAuth-capable MCP servers and their auth status",
async handler() {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
@@ -357,7 +358,7 @@ export const McpLogoutCommand = cmd({
type: "string",
}),
async handler(args) {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
@@ -448,7 +449,7 @@ export const McpAddCommand = cmd({
command: "add",
describe: "add an MCP server",
async handler() {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
@@ -618,7 +619,7 @@ export const McpDebugCommand = cmd({
demandOption: true,
}),
async handler(args) {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()

View File

@@ -13,7 +13,7 @@ import os from "os"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import { WithInstance } from "../../project/with-instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
import { text } from "node:stream/consumers"
@@ -303,7 +303,7 @@ export const ProvidersLoginCommand = cmd({
type: "string",
}),
async handler(args) {
await Instance.provide({
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()

View File

@@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import * as Log from "@opencode-ai/core/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { WithInstance } from "@/project/with-instance"
import {
readPackageThemes,
readPluginId,
@@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec)
return true
}
const ready = await Instance.provide({
const ready = await WithInstance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
@@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) {
}
runtime = next
try {
await Instance.provide({
await WithInstance.provide({
directory: cwd,
fn: async () => {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])

View File

@@ -1,8 +1,8 @@
import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "@/project/instance"
import { InstanceRuntime } from "@/project/instance-runtime"
import { WithInstance } from "@/project/with-instance"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
@@ -77,7 +77,7 @@ export const rpc = {
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
await WithInstance.provide({
directory: input.directory,
fn: async () => {
await upgrade().catch(() => {})

View File

@@ -40,7 +40,7 @@ import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { InstanceRuntime } from "@/project/instance-runtime"
import { InstanceLayer } from "@/project/instance-layer"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
@@ -93,17 +93,16 @@ export const AppLayer = Layer.mergeAll(
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
InstanceRuntime.layer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Worktree.appLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
).pipe(Layer.provideMerge(Observability.layer))
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">

View File

@@ -0,0 +1,11 @@
import { Effect, Layer } from "effect"
import { InstanceStore } from "./instance-store"
export const layer = Layer.unwrap(
Effect.promise(async () => {
const { InstanceBootstrap } = await import("./bootstrap")
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
}),
)
export * as InstanceLayer from "./instance-layer"

View File

@@ -1,27 +1,16 @@
import { makeRuntime } from "@/effect/run-service"
import { AppRuntime } from "@/effect/app-runtime"
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))
}),
)
// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service.
// Delete this module once those callers are migrated to Effect boundaries that
// provide InstanceStore directly.
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 const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input)))
export const disposeInstance = (ctx: InstanceContext) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))
export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll()))
export const reloadInstance = (input: LoadInput) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input)))
export * as InstanceRuntime from "./instance-runtime"

View File

@@ -8,26 +8,18 @@ import { type InstanceContext } from "./instance-context"
import { InstanceBootstrap } from "./bootstrap-service"
import * as Project from "./project"
export interface LoadInput<R = never> {
export interface LoadInput {
directory: string
/**
* Additional setup to run after the default InstanceBootstrap.
* Mainly used by tests for env-var setup or file writes that need the instance ALS context.
*/
init?: Effect.Effect<void, never, R>
worktree?: string
project?: Project.Info
}
export interface Interface {
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
readonly provide: <A, E, R, R2 = never>(
input: LoadInput<R2>,
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E, R | R2>
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
@@ -44,7 +36,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
const scope = yield* Scope.Scope
const cache = new Map<string, Entry>()
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
const boot = (input: LoadInput & { directory: string }) =>
Effect.gen(function* () {
const ctx: InstanceContext =
input.project && input.worktree
@@ -61,7 +53,6 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
})),
)
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"))
@@ -72,7 +63,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
return true
})
const completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
const completeLoad = (directory: string, input: LoadInput, entry: Entry) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(boot({ ...input, directory }))
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
@@ -108,7 +99,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
return true
})
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
const load = (input: LoadInput): Effect.Effect<InstanceContext> => {
const directory = AppFileSystem.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -126,7 +117,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
).pipe(Effect.withSpan("InstanceStore.load"))
}
const reload = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
const reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
const directory = AppFileSystem.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -180,7 +171,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
return yield* cachedDisposeAll
})
const provide = <A, E, R, R2>(input: LoadInput<R2>, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | R2> =>
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))

View File

@@ -1,15 +1,8 @@
import { Effect } from "effect"
import { context, type InstanceContext } from "./instance-context"
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 InstanceRuntime.load({ directory: input.directory, init: input.init })
return context.provide(ctx, async () => input.fn())
},
get current() {
return context.use()
},

View File

@@ -0,0 +1,10 @@
import { AppRuntime } from "@/effect/app-runtime"
import { context } from "./instance-context"
import { InstanceStore } from "./instance-store"
export async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: input.directory })))
return context.provide(ctx, () => input.fn())
}
export * as WithInstance from "./with-instance"

View File

@@ -6,7 +6,11 @@ import { InstanceStore } from "@/project/instance-store"
import { Provider } from "@/provider/provider"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
import { jsonRequest, runRequest } from "./trace"
import { Effect } from "effect"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server.config" })
export const ConfigRoutes = lazy(() =>
new Hono()
@@ -52,15 +56,28 @@ export const ConfigRoutes = lazy(() =>
},
}),
validator("json", Config.Info.zod),
async (c) =>
jsonRequest("ConfigRoutes.update", c, function* () {
const config = c.req.valid("json")
const cfg = yield* Config.Service
const store = yield* InstanceStore.Service
yield* cfg.update(config)
yield* store.dispose(yield* InstanceState.context)
return config
}),
async (c) => {
const result = await runRequest(
"ConfigRoutes.update",
c,
Effect.gen(function* () {
const config = c.req.valid("json")
const cfg = yield* Config.Service
yield* cfg.update(config)
return { config, ctx: yield* InstanceState.context }
}),
)
const response = c.json(result.config)
void runRequest(
"ConfigRoutes.update.dispose",
c,
InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe(
Effect.uninterruptible,
Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))),
),
)
return response
},
)
.get(
"/providers",

View File

@@ -18,7 +18,7 @@ import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Installation } from "@/installation"
import { InstanceRuntime } from "@/project/instance-runtime"
import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
@@ -152,7 +152,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
InstanceRuntime.layer,
MCP.defaultLayer,
ModelsDev.defaultLayer,
Permission.defaultLayer,
@@ -179,12 +178,13 @@ export function createRoutes(corsOptions?: CorsOptions) {
ToolRegistry.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Worktree.appLayer,
Bus.layer,
AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
]),
Layer.provideMerge(InstanceLayer.layer),
Layer.provideMerge(Observability.layer),
)
}

View File

@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"
import { Instance } from "@/project/instance"
import { WithInstance } from "@/project/with-instance"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceID } from "@/control-plane/schema"
@@ -20,7 +20,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler
return WorkspaceContext.provide({
workspaceID,
async fn() {
return Instance.provide({
return WithInstance.provide({
directory,
async fn() {
return next()

View File

@@ -6,7 +6,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Workspace } from "@/control-plane/workspace"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppRuntime } from "@/effect/app-runtime"
import { Instance } from "@/project/instance"
import { WithInstance } from "@/project/with-instance"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { Effect } from "effect"
@@ -97,7 +97,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(workspaceID),
fn: () =>
Instance.provide({
WithInstance.provide({
directory: target.directory,
async fn() {
return next()

View File

@@ -1,7 +1,8 @@
import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { Global } from "@opencode-ai/core/global"
import { Instance } from "../project/instance"
import { InstanceLayer } from "@/project/instance-layer"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
@@ -159,7 +160,12 @@ type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
| AppFileSystem.Service
| Path.Path
| ChildProcessSpawner.ChildProcessSpawner
| Git.Service
| Project.Service
| InstanceStore.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -169,6 +175,7 @@ export const layer: Layer.Layer<
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const gitSvc = yield* Git.Service
const project = yield* Project.Service
const store = yield* InstanceStore.Service
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
@@ -251,13 +258,10 @@ export const layer: Layer.Layer<
return
}
const booted = yield* Effect.promise(() =>
Instance.provide({
directory: info.directory,
fn: () => undefined,
})
.then(() => true)
.catch((error) => {
const booted = yield* store.load({ directory: info.directory }).pipe(
Effect.as(true),
Effect.catch((error) =>
Effect.sync(() => {
const message = errorMessage(error)
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
@@ -268,6 +272,7 @@ export const layer: Layer.Layer<
})
return false
}),
),
)
if (!booted) return
@@ -579,7 +584,7 @@ export const layer: Layer.Layer<
}),
)
export const defaultLayer = layer.pipe(
export const appLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Project.defaultLayer),
@@ -587,4 +592,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(NodePath.layer),
)
export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer))
export * as Worktree from "."