Effect 运行时与 Location
为什么整体迁向 Effect,LayerNode 依赖图如何编译成 Layer,InstanceState 与 Location 如何按目录隔离服务,以及回调怎样安全地回到 Effect。
第 6 部分讲正在进行中的 V2 重构。它的地基不是某个新模块,而是一种统一的写法:全仓库使用 Effect v4(根 package.json 的 catalog 里是 4.0.0-beta.83)。packages/core 有 316 个 TypeScript 文件、约 3.3 万行,几乎每个服务都是同一个形状:Context.Service 定义接口,Layer 提供实现,再包成一个依赖节点。
为什么是 Effect
仓库里没有一篇“为什么选 Effect”的宣言,但约束散落在几份文档里,拼起来很清楚:
- 失败显式:
packages/opencode/AGENTS.md要求用Schema.TaggedErrorClass定义预期错误,用Effect.fn("Domain.method")给服务方法命名并自动追踪;路线图packages/opencode/specs/effect/todo.md把类型化错误列为 P0。 - 依赖显式:服务在 Layer 构造时一次性取得依赖,缺什么在类型上就能看出来。连运行时开关也是服务(
RuntimeFlags),测试换一个 Layer 即可,不再改process.env。 - 生命周期显式:订阅、子进程、后台纤程都挂在 Scope 上,作用域关闭即清理;取消就是中断,V2 工具规格原话是 “Effect interruption is the cancellation mechanism.”
同一份路线图的 P4 点出了迁移中最难的一块:
Remove ambient Instance coupling while keeping Promise/callback interop.
去掉“环境里隐含的当前项目”,同时让老的 Promise 与回调代码还能调进来。下面几节都围绕这件事。
LayerNode:把依赖声明成图
原生 Effect 组装服务靠手写 Layer.provide 链,几十个服务之后就难以维护。packages/core/src/effect/layer-node.ts 加了一层薄薄的“依赖节点”:节点记录服务、实现与依赖列表,compile 沿图递归编译并自动 provide。它把两道关:deps 漏了实现所需的服务,类型上会出现 "Missing dependencies";依赖成环,遍历时直接抛出 Cycle detected in layer tree。
节点带标签,标签规定了依赖方向:
export const tags = LayerNode.tags({
location: ["global"],
global: [],
})
// ...
export const makeGlobalNode = tags.make("global")
export const makeLocationNode = tags.make("location")packages/core/src/effect/app-node.ts:3(删减后)。location 节点可以依赖 global 节点,反之不行。core 源码里约有 33 处 makeGlobalNode、51 处 makeLocationNode。
另有两种特殊节点:unbound 只声明服务不给实现(如 SessionExecution.node),由入口绑定;group 只负责打包。入口统一调用 AppNodeBuilder.build(root, replacements),替换必须同名、同标签,否则直接抛错;若图里有未绑定的 LocationServiceMap.node,它还会自动生成 Location 服务表。
Location:按目录实例化整张服务图
Location.Ref 只有两个字段:绝对路径 directory 与可选的 workspaceID。Location.boundNode(ref) 借 Project.Service 解析出项目 ID、根目录与 VCS,得到 Location.Service。
packages/core/src/location-services.ts 把 36 个 location 节点编成一组(配置、Agent、Catalog、插件、文件系统、PermissionV2、工具注册表、SessionRunner 等),再用 LayerMap 按 Ref 缓存:
LayerMap.make(
(ref: Location.Ref) => {
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// ...
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe(
Layer.fresh,
// ...
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: "60 minutes" },
),packages/core/src/location-services.ts:89(删减后)。三个要点:
- hoist:遍历时把带 global 标签的依赖(数据库、事件总线等)从 Location 图里抽出来,单独编译后垫在下面;Location 部分经
Layer.fresh编译,每个目录一份。 - 按值缓存:Ref 结构相等即命中同一份服务,
packages/core/test/location-layer.test.ts专门验证了构造出的 Ref 与解码出的 Ref 命中同一份。 - 空闲回收:60 分钟无人使用的 Location 会被释放,作用域里的 finalizer 随之运行。
谁来选 Location?新服务端的中间件(packages/server/src/location.ts)从查询参数 location[directory] 或请求头 x-opencode-directory 算出 Ref,再 Effect.provide(locations.get(ref));带会话 ID 的路由则先查 session 表里记录的目录。业务代码只管 yield* Location.Service,不必把目录当参数层层传递。
InstanceState:V1 的解法
同一个问题——一个进程打开多个目录,服务状态不能串——V1 在 packages/opencode 里的解法是:服务只有一份,状态按目录缓存。
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))packages/opencode/src/effect/instance-state.ts:30。get 以当前纤程上 InstanceRef 的目录为键;初始化闭包里 forkScoped 的后台纤程、acquireRelease 的资源都属于这个键的作用域。InstanceStore 释放某个目录时,所有注册过的缓存一起 invalidate,清理随作用域关闭自动发生。AGENTS.md 的原话:
It uses
ScopedCachekeyed by directory — each open project gets its own state, automatically cleaned up on disposal.
packages/opencode/src 里有 22 个文件在用 InstanceState.make,包括 config、lsp、mcp、plugin、provider、permission。
| V1:InstanceState | V2:Location | |
|---|---|---|
| 隔离粒度 | 服务内部的某份状态 | 整张服务图 |
| 当前目录从哪来 | 纤程上的 InstanceRef | Location.Service |
| 清理时机 | InstanceStore 显式释放 | LayerMap 空闲超时或作用域关闭 |
| 服务作者的负担 | 每个服务记得套一层 | 声明成 location 节点即可 |
EffectBridge:回调边界
@parcel/watcher、node-pty、fs.watch 和插件回调都不在 Effect 纤程里执行,回调触发时 InstanceRef 早已丢失。packages/opencode/src/effect/bridge.ts 的 EffectBridge.make() 在 Effect 内先捕获当前上下文与 InstanceRef、WorkspaceRef,返回 promise、fork、run、bind 四种形态供回调重新进入 Effect,并恢复仍在使用的 WorkspaceContext(AsyncLocalStorage)。反方向由 run-service.ts 的 attach 负责:AppRuntime 的每个 run 方法先把调用点的 InstanceRef 与工作区 ID 带进新执行。
packages/opencode/specs/effect/instance-context.md 定下规矩:不许再新增环境式实例全局量,Promise 与回调边界要么留在 Effect 里,要么走 EffectBridge,要么显式传参。
两边的运行时组装
opencode(V1):packages/opencode/src/effect/app-runtime.ts 把 48 个节点编进 AppLayer,再 ManagedRuntime.make(AppLayer, { memoMap })。全仓共享的 memoMap 定义只有一行 Layer.makeMemoMapUnsafe(),用来对 Layer 去重。Effect 指南要求跨入 Effect 优先走 AppRuntime,makeRuntime 只留给有意为之的服务局部边界。打开项目时 InstanceStore 调 InstanceBootstrap.run:先读配置,再初始化插件(插件能改配置),然后并发初始化 LSP、分享、格式化、VCS、快照与项目,单个失败只记警告。
core(V2):没有全局运行时单例,由各入口自行组装,例如:
const serviceLayer = AppNodeBuilder.build(applicationServices, [[SessionExecution.node, SessionExecutionLocal.node]])packages/server/src/routes.ts:52。applicationServices 里是数据库、EventV2、HTTP 客户端、工具输出清理、SessionV2、保存的权限、凭据、PTY 相关服务与 LocationServiceMap。旧 opencode 的 HTTP 服务也以同样方式把 V2 会话服务挂进来,两代服务端因此能在一个进程里并存。
最后留意 packages/opencode/src/effect/runner.ts:它是 V1 SessionRunState 用的单会话运行器状态机(Idle、Running、Shell、ShellThenRun)。下一篇会看到,V2 用 SessionRunCoordinator 换了一种思路。
下一篇:V2 会话内核——提示的受理与执行为什么要拆开。