首页
/ opencode 的 Effect 实例上下文:用 InstanceRef、InstanceState、InstanceStore 与 EffectBridge 管理多项目运行时的上下文传递

opencode 的 Effect 实例上下文:用 InstanceRef、InstanceState、InstanceStore 与 EffectBridge 管理多项目运行时的上下文传递

2026-09-06 22:27:08作者:蔡丛锟

在 opencode 的多项目、多工作区运行时中,"当前操作的是哪个项目实例" 是一个贯穿服务、HTTP 中间件、原生回调与插件边界的横切问题。本篇基于规格文档 instance-context.md,结合仓库内 instance-ref.tsinstance-state.tsinstance-store.tsbridge.ts 等实现源码,系统讲解 opencode 如何将"实例选择"建模为 Effect 提供的上下文:读完后可掌握 InstanceRef / WorkspaceRef / InstanceState / InstanceStore / EffectBridge 这五组 API 的适用场景、底层缓存与失效机制,以及在 Promise 或回调边界中正确携带实例上下文的重入方案。

核心命题:实例选择是 Effect 提供的上下文,而不是全局变量

规格文档开宗明义地给出了一条架构决策:

Instance selection is now Effect-provided context.

这意味着 opencode 内部不再依赖某个"当前项目"的全局可变状态来确定实例,而是把实例选择建模为 Effect 的 Context.Reference:每段 Effect 代码通过依赖声明拿到上下文,运行器在执行时按依赖注入。文档同时给出了一条明确的约束红线:

Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use EffectBridge, or pass the required context explicitly.

即:禁止新增任何新的"环境性"实例全局量;处于 Promise 或回调边界(原生绑定、插件钩子、普通 JavaScript 回调)的代码,要么停留在 Effect 世界内,要么通过 EffectBridge 重入 Effect,要么把所需上下文显式传参。仓库中仍保留的 AsyncLocalStorage 形态上下文(如 workspace-context.ts 中的 WorkspaceContext)属于遗留兼容面,EffectBridge.fromPromise 的注释也明确说明它会 "restore workspace ALS first" 以兼容仍从 ALS 读取工作区的旧回调代码。

从源码结构看,这五组 API 的分工是:

API 定位 源码位置
InstanceRef / WorkspaceRef Effect 上下文引用,承载"当前项目实例"与"当前工作区 id" instance-ref.ts
InstanceState 在 Effect 服务内部按实例目录缓存派生状态 instance-state.ts
InstanceStore 入口边界加载、重载、销毁项目实例上下文的服务 instance-store.ts
EffectBridge 从 Effect 重入原生/插件/普通 JS 回调边界 bridge.ts

InstanceContext:上下文的实际载荷

InstanceRef 携带的值类型是 InstanceContext,定义非常收敛:

export interface InstanceContext {
  directory: string   // 实例所在目录(绝对路径,经 FSUtil.resolve 解析)
  worktree: string    // 沙箱/worktree 根目录;非 git 项目会退化为 "/"
  project: Project.Info
}

同一文件中还提供了边界判定工具 containsPath:它判断一个路径是否落在 ctx.directoryctx.worktree 内,并特意处理了非 git 项目 worktree === "/" 会匹配任意绝对路径的陷阱——此时跳过 worktree 检查以保留 external_directory 权限语义。这说明实例上下文不只是"记录当前在哪",还直接参与权限判定。

InstanceRef 与 WorkspaceRef:两个 Effect 上下文引用

instance-ref.ts 全文只有两个声明:

export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
  defaultValue: () => undefined,
})

export const WorkspaceRef = Context.Reference<WorkspaceV2.ID | undefined>("~opencode/WorkspaceRef", {
  defaultValue: () => undefined,
})

两个关键设计点:

  1. 类型是 T | undefined 且默认值为 undefined。上下文"可能存在、也可能不存在"被直接编码进类型:全局服务(不绑定具体项目)可以不携带 InstanceRef,而需要实例的服务在读取时先判空。这避免了"强行给一个假的当前项目"的问题。
  2. InstanceRefWorkspaceRef 分离。项目实例(directory/worktree/project)和工作区 id 是两个独立的维度:一个工作区下可以运行多个项目实例,一个请求的上下文中两者都需要,但可以分别注入、分别缺失。

在 HTTP 服务面,这两个引用的注入发生在 instance-context.ts 中间件中:

const route = yield* WorkspaceRouteContext
const ctx = yield* store.load({ directory: decode(route.directory) })
return yield* effect.pipe(
  Effect.provideService(InstanceRef, ctx),
  Effect.provideService(WorkspaceRef, route.workspaceID),
)

中间件从路由中解析出目录(并做 decodeURIComponent 容错),调用 InstanceStore.load 取得实例上下文,再一次性 provideService 注入下游。下游所有 handler 因此无需感知"加载实例"这件事,只声明依赖即可——这正是"实例选择是 Effect 提供的上下文"这一命题在请求生命周期中的落点。

InstanceState:服务内部按实例目录缓存派生状态

许多服务需要为"某个项目目录"持有派生状态(例如已解析的配置、已建立的连接)。InstanceState 把这类状态建模为以目录为 key 的 ScopedCache,核心实现在 instance-state.ts

读取当前实例:context 与 directory

export const context = Effect.gen(function* () {
  const ctx = yield* InstanceRef
  if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided"))
  return ctx
})

export const directory = Effect.map(context, (ctx) => ctx.directory)

这里有一个与 InstanceRef 默认值 undefined 相呼应的决策:在"必须有实例"的服务内部,读不到 InstanceRef 属于不可恢复的环境错误,直接用 Effect.die 终止而非降级处理。directory 则是缓存 key 的提取器;workspaceID 辅助读取则展示了双引用回退链:先查 WorkspaceRef,再回退到遗留的 WorkspaceContext.workspaceID(ALS)。

make / get / invalidate:缓存的生命周期

export const make = <A, E = never, R = never>(
  init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
) =>
  Effect.gen(function* () {
    const cache = yield* ScopedCache.make<string, A, E, R>({
      capacity: Number.POSITIVE_INFINITY,
      lookup: () => Effect.gen(function* () {
        return yield* init(yield* context)
      }),
    })

    const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
    yield* Effect.addFinalizer(() => Effect.sync(off))
    return { [TypeId]: TypeId, cache }
  })

要点:

  • 容量无限Number.POSITIVE_INFINITY):一个服务端进程可能同时服务多个项目实例,缓存上限由"实例的销毁"来管理,而不是 LRU 淘汰。
  • lookup 中再次读取 contextinit 回调拿到的 InstanceContext 来自调用时刻的 InstanceRef,即"为哪个目录建缓存"由当次 Effect 的上下文决定,而不是外部传参。
  • 注册 disposermake 时向 instance-registry.ts 全局注册器挂上一个 directory => invalidate(cache, directory) 的清理函数,并在 Scope 关闭时反注册(addFinalizer)。这个注册器是 InstanceStore 与所有 InstanceState 之间的解耦纽带:InstanceState 不需要认识 InstanceStore,销毁时由注册器统一广播。

读写 API 则非常薄,全部委托给 ScopedCache

export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
  Effect.gen(function* () {
    return yield* ScopedCache.get(self.cache, yield* directory)  // key = 当前实例目录
  })

export const use = <A, E, R, B>(self, select: (value: A) => B) => Effect.map(get(self), select)
export const useEffect = <A, E, R, B, E2, R2>(self, select: (value: A) => Effect.Effect<B, E2, R2>) =>
  Effect.flatMap(get(self), select)
export const has = <A, E, R>(self) => /* ScopedCache.has(self.cache, directory) */
export const invalidate = <A, E, R>(self) => /* ScopedCache.invalidate(self.cache, directory) */

即:同一份服务、同一时刻只能为一个实例服务;切换实例(即切换 InstanceRef)后,get 自然命中另一个缓存 key,得到独立的状态。

测试文件 instance-state.test.ts 对这三条核心性质做了直接验证:

// 同一目录命中同一缓存(init 只执行一次)
const a = yield* access(state, dir)
const b = yield* access(state, dir)
expect(a).toBe(b)
expect(n).toBe(1)

// 不同目录互相隔离
expect(a).not.toBe(b)   // dir one vs dir two

// reload 后缓存失效、资源被 release(acquireRelease 的 release 恰好触发一次)
yield* reloadInstance({ directory: dir })
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])

最后一条用例同时验证了与 InstanceStore.reload 的联动:重载实例会触发 ScopedCache.invalidate,从而让带 Scope 的资源(Effect.acquireRelease)先走释放逻辑再重建。

InstanceStore:入口边界的加载、重载与销毁

规格文档把 InstanceStore 定位在 "entry boundaries"——即那些需要真正创建、切换、销毁项目实例上下文的位置(HTTP 入口、CLI 命令、测试夹具),而非服务内部。instance-store.ts 定义了服务接口:

export interface Interface {
  readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
  readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
  readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
  readonly disposeDirectory: (directory: string) => Effect.Effect<void>
  readonly disposeAll: () => Effect.Effect<void>
  readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}

其中 LoadInput 允许调用方显式指定 worktreeproject(用于 worktree 适配器等场景),否则由 Project.fromDirectory 解析:

const ctx: InstanceContext =
  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 })),
      )
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))

注意 boot 的最后一步:用刚构造出的 ctx 注入 InstanceRef 再运行 InstanceBootstrap——引导过程本身就跑在"这个实例"的上下文里,因此引导逻辑里的一切 InstanceState.make 都天然归属该目录。

并发加载:Deferred 单例化

load 的实现用一个 Map<directory, { deferred }> 做并发去重,结构上很典型:

const load = (input) => {
  const directory = FSUtil.resolve(input.directory)
  return Effect.uninterruptibleMask((restore) =>
    Effect.gen(function* () {
      const existing = cache.get(directory)
      if (existing) return yield* restore(Deferred.await(existing.deferred))  // 等待已在进行中的加载
      const entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
      cache.set(directory, entry)
      yield* completeLoad(directory, input, entry).pipe(Effect.forkIn(scope, { startImmediately: true }))
      return yield* restore(Deferred.await(entry.deferred))
    }),
  ).pipe(Effect.withSpan("InstanceStore.load"))
}

同一目录的 N 个并发请求只会触发一次引导;uninterruptibleMask 保证"登记 entry"这一步原子性,且只有 Deferred.await 是可中断的(restore 包裹)。加载失败时 completeLoad 会摘除 entry,让后续请求可以重试。

销毁链:disposers 广播 + 事件发布

disposeContext 把三件事串起来:

yield* Effect.promise(() => runDisposers(ctx.directory))   // 触发 instance-registry 中所有 InstanceState 的 invalidate
yield* emitDisposed({ directory: ctx.directory, ... })     // 通过 GlobalBus 发布 "server.instance.disposed" 事件

dispose 还处理了版本竞争:等待 entry 完成后,若 exit.value !== ctx(期间发生了 reload,缓存已换了一代),就直接返回、不动新实例;disposeEntry 在运行前后各检查一次 cache.get(directory) === entry,防止销毁过程中误删新 entry。disposeAllEffect.cachedWithTTL(..., Duration.zero) 做"进程内只真正执行一次"的缓存,配合 addFinalizer 在 Layer 关闭时兜底清理。

provide 是入口边界的组合糖:

const provide = (input, effect) =>
  load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))

一次调用完成"加载实例 + 在其上下文中运行 Effect",适用于 CLI 命令、测试夹具等一次性入口。

EffectBridge:原生、插件与普通 JS 回调边界的重入方案

最难的是跨越 Effect 世界之外的边界:原生绑定回调、插件钩子、返回 Promise 的第三方代码。规格文档给出的规则是"要么留在 Effect、要么用 EffectBridge、要么显式传参"。bridge.ts 是这条规则的实现。

捕获与恢复

function captureSync() {
  const fiber = Fiber.getCurrent()
  const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
  const workspace =
    (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID
  return { instance, workspace }
}

captureSync 从当前 Fiber 的上下文里同步读出 InstanceRef / WorkspaceRef(缺失时回退到遗留的 WorkspaceContext ALS),随后所有重入都把捕获到的引用重新 provideService 回去——run-service.ts 里的 attachWith 负责这一步:

export function attachWith<A, E, R>(effect, refs) {
  if (!refs.instance) return effect.pipe(Effect.provideService(WorkspaceRef, refs.workspace))
  if (!refs.workspace) return effect.pipe(Effect.provideService(InstanceRef, refs.instance))
  return effect.pipe(
    Effect.provideService(InstanceRef, refs.instance),
    Effect.provideService(WorkspaceRef, refs.workspace),
  )
}

Shape 的四个出口

EffectBridge.make() 返回的对象(Shape)提供四种重入方式,覆盖了不同回调形态:

{
  promise: (effect) => restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))),
  fork:    (effect) => restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))),
  run:     (effect) => Effect.callback((resume) => /* runPromiseExit 后把 Exit 拆回 Effect */),
  bind:    (fn) => (...args) => /* runSync(wrap(Effect.sync(() => fn(...args)))) */
}
  • promise:在 Effect 内等待一个由 Effect 驱动产生的 Promise 结果;
  • fork:非阻塞启动;
  • run:返回 Effect,把外部回调的"退出结果"(成功值或失败因)无损传回 Effect 世界;
  • bind:把一个普通同步函数包成"每次调用都会携带被捕获上下文执行"的闭包,适合注册给插件系统的同步钩子。

另有两条面向"Effect 出、JS 进"方向的工具:

  • bind(fn)(模块级):立即捕获当前 Fiber 的引用,返回包装函数,调用时通过 Effect.runSync 在捕获的上下文中执行——这是"给原生/插件世界注册回调"的标准写法;
  • fromPromise(fn):从 Effect 调用返回 Promise 的 JS 代码,注释明确说明它"mirrors Effect.promise but restores workspace ALS first",以兼容仍从 AsyncLocalStorage 读取工作区的旧代码;同时提醒——纯 JS 回调若需要 InstanceRef,应显式接收该引用。

这两者正好对应规格文档给出的三种姿态:留在 Effect 内fromPromise)、EffectBridge 重入make() / bind)、显式传参(纯 JS 侧)。

与遗留 ALS 的共存关系

仓库里还能看到旧一代基于 LocalContext(AsyncLocalStorage)的上下文实现,例如 workspace-context.tsWorkspaceContextinstance-context.ts 中的 LocalContext.create<InstanceContext>("instance")。从源码结构看,当前的迁移策略不是"一夜删除",而是分层共存:

  1. 新代码一律读写 InstanceRef / WorkspaceRef(Effect 上下文,随 Fiber 传播,无隐式全局可变状态);
  2. EffectBridge 在跨越边界时同时恢复 workspace ALS,保证仍从 ALS 读值的旧回调不失联;
  3. InstanceState.workspaceID 等辅助读取处保留 WorkspaceContext.workspaceID 回退链,作为过渡期的兼容层。

这与规格文档"do not add new ambient instance globals"的表述一致:遗留 ALS 只减不增。

小结:选型决策表

把规格文档的五组 API 与源码证据合起来,可以整理成一张落地时的选型表:

场景 应使用 依据
Effect 服务内部读取当前项目目录/工作区 InstanceRef / WorkspaceRef / InstanceState.directory instance-ref.tsinstance-state.ts
服务需要按项目目录缓存状态(配置、连接等) InstanceState.make + get / useEffect / invalidate 缓存 key 为 directory,失效由 instance registry 广播(instance-registry.ts
HTTP/CLI 等入口边界加载、重载、销毁实例 InstanceStore.load / reload / dispose* / provide instance-store.ts,含 Deferred 并发去重与版本竞争保护
请求级注入(服务端) HttpApi 中间件统一 provideService 两个引用 instance-context.ts
原生/插件/普通 JS 回调需要重入 Effect EffectBridgemake()promise/fork/run/bind,或模块级 bind / fromPromise bridge.ts
纯 JS 侧需要实例信息 显式传参,不引入新的全局量 规格文档约束条款

这套设计的核心收益在于:实例上下文的生命周期(创建、失效、销毁)被统一到 Effect 的 Scope、ScopedCache 与 disposer 注册器中,"当前项目"从一个隐式全局概念变成了可追踪、可测试(instance-state.test.ts)、可在任意边界显式携带的依赖声明。

登录后查看全文
热门项目推荐
相关项目推荐