# Effect 运行时与 Location

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

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.org/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/opencode/effect-runtime
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

第 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`。

节点带标签，标签规定了依赖方向：

```ts
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 缓存：

```ts
    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` 里的解法是：服务只有一份，状态按目录缓存。

```ts
    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：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）**：没有全局运行时单例，由各入口自行组装，例如：

```ts
  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 会话内核](https://daiw.org/manual/opencode/session-v2)——提示的受理与执行为什么要拆开。
