Effect 运行时与 Location

为什么整体迁向 Effect,LayerNode 依赖图如何编译成 Layer,InstanceState 与 Location 如何按目录隔离服务,以及回调怎样安全地回到 Effect。

作者 David更新于 第 33 篇(共 38 篇)

第 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(删减后)。三个要点:

  1. hoist:遍历时把带 global 标签的依赖(数据库、事件总线等)从 Location 图里抽出来,单独编译后垫在下面;Location 部分经 Layer.fresh 编译,每个目录一份。
  2. 按值缓存:Ref 结构相等即命中同一份服务,packages/core/test/location-layer.test.ts 专门验证了构造出的 Ref 与解码出的 Ref 命中同一份。
  3. 空闲回收: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 ScopedCache keyed 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:InstanceStateV2:Location
隔离粒度服务内部的某份状态整张服务图
当前目录从哪来纤程上的 InstanceRefLocation.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 会话内核——提示的受理与执行为什么要拆开。

本页目录