opencode 的 Effect 实例上下文:用 InstanceRef、InstanceState、InstanceStore 与 EffectBridge 管理多项目运行时的上下文传递
在 opencode 的多项目、多工作区运行时中,"当前操作的是哪个项目实例" 是一个贯穿服务、HTTP 中间件、原生回调与插件边界的横切问题。本篇基于规格文档 instance-context.md,结合仓库内 instance-ref.ts、instance-state.ts、instance-store.ts、bridge.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.directory 或 ctx.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,
})
两个关键设计点:
- 类型是
T | undefined且默认值为undefined。上下文"可能存在、也可能不存在"被直接编码进类型:全局服务(不绑定具体项目)可以不携带InstanceRef,而需要实例的服务在读取时先判空。这避免了"强行给一个假的当前项目"的问题。 InstanceRef与WorkspaceRef分离。项目实例(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 中再次读取
context:init回调拿到的InstanceContext来自调用时刻的InstanceRef,即"为哪个目录建缓存"由当次 Effect 的上下文决定,而不是外部传参。 - 注册 disposer:
make时向 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 允许调用方显式指定 worktree 与 project(用于 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。disposeAll 用 Effect.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 代码,注释明确说明它"mirrorsEffect.promisebut restores workspace ALS first",以兼容仍从 AsyncLocalStorage 读取工作区的旧代码;同时提醒——纯 JS 回调若需要InstanceRef,应显式接收该引用。
这两者正好对应规格文档给出的三种姿态:留在 Effect 内(fromPromise)、用 EffectBridge 重入(make() / bind)、显式传参(纯 JS 侧)。
与遗留 ALS 的共存关系
仓库里还能看到旧一代基于 LocalContext(AsyncLocalStorage)的上下文实现,例如 workspace-context.ts 的 WorkspaceContext 和 instance-context.ts 中的 LocalContext.create<InstanceContext>("instance")。从源码结构看,当前的迁移策略不是"一夜删除",而是分层共存:
- 新代码一律读写
InstanceRef/WorkspaceRef(Effect 上下文,随 Fiber 传播,无隐式全局可变状态); EffectBridge在跨越边界时同时恢复 workspace ALS,保证仍从 ALS 读值的旧回调不失联;InstanceState.workspaceID等辅助读取处保留WorkspaceContext.workspaceID回退链,作为过渡期的兼容层。
这与规格文档"do not add new ambient instance globals"的表述一致:遗留 ALS 只减不增。
小结:选型决策表
把规格文档的五组 API 与源码证据合起来,可以整理成一张落地时的选型表:
| 场景 | 应使用 | 依据 |
|---|---|---|
| Effect 服务内部读取当前项目目录/工作区 | InstanceRef / WorkspaceRef / InstanceState.directory |
instance-ref.ts、instance-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 | EffectBridge(make() 的 promise/fork/run/bind,或模块级 bind / fromPromise) |
bridge.ts |
| 纯 JS 侧需要实例信息 | 显式传参,不引入新的全局量 | 规格文档约束条款 |
这套设计的核心收益在于:实例上下文的生命周期(创建、失效、销毁)被统一到 Effect 的 Scope、ScopedCache 与 disposer 注册器中,"当前项目"从一个隐式全局概念变成了可追踪、可测试(instance-state.test.ts)、可在任意边界显式携带的依赖声明。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00