首页
/ opencode Effect 编程规范实战指南:Service 形态、运行时边界、InstanceState 与测试模式

opencode Effect 编程规范实战指南:Service 形态、运行时边界、InstanceState 与测试模式

2026-09-06 11:27:31作者:田桥桑Industrious

本文基于 opencode 仓库中的 Effect 开发规范 guide.md,系统讲解 packages/opencode 中 Effect 代码的标准写法:单一 Service 模块的组织形态、以 AppRuntime 为统一入口的运行时边界、类型化的 RuntimeFlags、按目录隔离的 InstanceState、错误通道纪律、HTTP 边界与 testEffect 测试模式。读完本文,你可以按仓库既定规范独立编写新的 Effect 服务、完成存量代码迁移,并用源码级证据理解每一项规范背后的设计意图。

一、指南定位:新代码与迁移的目标形态

这份指南描述的是 packages/opencode 中 Effect 代码的首选形态(preferred shape),适用于两类场景:

  • 新写的 Effect 功能,直接按规范落地;
  • 存量文件迁移——指南明确:只有当 legacy 文件本身已在改动范围内时才顺带迁移("If a legacy file differs, migrate it only when it is already in scope"),避免制造无关的大 diff。

与之配套的是清理路线图 todo.md,它把清理工作划分为 P0–P6 优先级:P0 是"错误类型化 + 错误渲染 + HTTP 边界",P1 是测试迁移,P2 是可变 Flag 的删除,P3 是全局路径显式化,P4 是实例上下文与桥接边界,P5/P6 是进程/文件系统服务化与 OpenAPI 兼容层收缩。理解这个优先级有助于判断:当你修改某个文件时,哪类反模式最应该顺手消灭。

二、Service 形态:一个模块一个服务

规范要求每个 Effect 服务使用一个扁平模块:顶层扁平导出、带 trace 的方法、显式 layer、文件底部自导出(self-reexport)。标准骨架如下(完整继承自指南原文):

export interface Interface {
  readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}

export const layer = Layer.effect(
  Service,
  Effect.gen(function* () {
    const state = yield* InstanceState.make<State>(Effect.fn("Foo.state")(() => Effect.succeed({})))

    const get = Effect.fn("Foo.get")(function* (id: FooID) {
      const s = yield* InstanceState.get(state)
      return yield* loadFoo(s, id)
    })

    return Service.of({ get })
  }),
)

export const defaultLayer = layer.pipe(Layer.provide(FooDep.defaultLayer))

export * as Foo from "./foo"

配套规则:

  • 禁止 export namespace Foo { ... }。namespace 会遮蔽类型导出、破坏 IDE 跳转与 tree-shaking 的扁平性;改用 interface + class + const 三件套。
  • 公共服务方法用 Effect.fn("Foo.method")。方法名作为 trace span 名(Foo.method 命名空间点分法),保证链路追踪中每个方法可独立定位。
  • 小的内部辅助函数用 Effect.fnUntraced——不需要独立 span 时不产生额外追踪开销。
  • 辅助函数保持为同文件内非导出的顶层声明,不引入额外模块。
  • 自导出index.ts 中用 export * as Foo from ".",其他文件用 export * as Foo from "./foo"。这使整个模块可以像 Foo.ServiceFoo.defaultLayerFoo.Interface 一样被命名空间化引用。仓库中大量现存服务已采用此形态,例如 compaction.ts 末尾的 export * as SessionCompaction from "./compaction"instruction.ts 末尾的 export * as Instruction from "./instruction"
  • 特例:src/config 目录下保留文件顶部自导出的既有模式。

layerdefaultLayer 的分工是关键设计:layer开放依赖的层(尚未 provide 外部服务),defaultLayer 是把依赖闭包起来的封闭层。测试与上层装配时,开放层允许替换内部依赖;封闭层则直接可跑。这一区分在测试章节还会再次出现。

三、运行时边界:一切代码经由 AppRuntime

指南要求大部分代码统一通过 app-runtime.ts 中的 AppRuntime 运行。查看源码可以看到其构成:

// packages/opencode/src/effect/app-runtime.ts (节选)
export const AppLayer = AppNodeBuilderV1.build(
  LayerNode.group([
    Npm.node, FSUtil.node, Database.node, Auth.node, Account.node, Config.node,
    Git.node, Storage.node, Snapshot.node, Plugin.node, ModelsDev.node, Provider.node,
    // ... 约 48 个服务节点:Session、LLM、MCP、ToolRegistry、Workspace 等
  ]),
).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer))

const rt = ManagedRuntime.make(AppLayer, { memoMap })

从源码结构看,AppLayer 由约 48 个服务节点(Session、Provider、LLM、MCP、ToolRegistry、Workspace 等)通过 LayerNode.group 组合而成,并全局共享同一个 memoMap——这是指南所说"shares the global memoMap"的实现,意味着同一 runtime 内各服务的单例语义是一致的。

AppRuntime 暴露 runSync / runPromise / runPromiseExit / runFork / runCallback / dispose,每个执行入口都经过一层 wrapapp-runtime.ts#L116-L134)。wrap 调用 run-service.ts 中的 attach(effect)

export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
  const workspace = WorkspaceContext.workspaceID
  const fiber = Fiber.getCurrent()
  return attachWith(effect, {
    instance: fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined,
    workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
  })
}

这正是指南所说"restore the current instance/workspace refs when crossing from non-Effect code"的机制:当 Effect 从普通 JS/异步代码边界重新启动时,attach 会从当前 fiber 或 WorkspaceContext(AsyncLocalStorage)中捕获 InstanceRef / WorkspaceRefprovideService 注入,使实例上下文跨边界不丢失。

实践规则:

  • 在 CLI 命令、HTTP handler、普通 async 适配器等应用边界上,使用 AppRuntime.runPromise(effect)
  • makeRuntime(...)(见 run-service.ts#L33-L47)仍为少数有意的服务级局部边界和迁移遗留而存在,除非服务确实无法并入 AppLayer,否则不要新增 service-local runtime。

四、运行时标志:RuntimeFlags.Service 取代可变 Flag

指南要求通过 runtime-flags.tsRuntimeFlags.Service 读取 opencode 运行时标志,禁止通过可变 Flag 对象或运行时迟到的 process.env 读取。查看实现,这是一个 ConfigService 派生服务,把环境变量一次性解析为类型化的只读结构:

// packages/opencode/src/effect/runtime-flags.ts (节选)
export class Service extends ConfigService.Service<Service>()("@opencode/RuntimeFlags", {
  autoShare: bool("OPENCODE_AUTO_SHARE"),
  pure: bool("OPENCODE_PURE"),
  disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
  disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
  disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
  disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"),
  disableClaudeCodePrompt: Config.all({
    broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
    direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
  }).pipe(Config.map((flags) => flags.broad || flags.direct)),
  enableExa: Config.all({
    experimental,
    enabled: bool("OPENCODE_ENABLE_EXA"),
    legacy: bool("OPENCODE_EXPERIMENTAL_EXA"),
  }).pipe(Config.map((flags) => flags.experimental || flags.enabled || flags.legacy)),
  experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
  outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
  bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
  client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
  // ... 共 20+ 个标志
}) {}

两个值得注意的实现细节:

  • enabledByExperimental 总开关模式experimentalReferences 等标志由 OPENCODE_EXPERIMENTAL 全局实验开关与具体开关共同决定(Option.getOrElse(flags.enabled, () => flags.experimental)),即设置 OPENCODE_EXPERIMENTAL 会批量解锁所有 experimental* 标志;
  • 数值型标志带校验positiveInteger 通过 Config.map 把非正整数收敛为 undefinedoutputTokenMaxbashDefaultTimeoutMs 即采用此模式。

测试侧的标准写法是显式 layer 变体,而非改环境变量:

const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalReferences: true }))))

RuntimeFlags.layer(overrides)runtime-flags.ts#L66-L73 的实现正是把覆盖项与空配置解析出的默认值做 { ...flags, ...overrides } 合并,因此该写法在测试中是精确可控的。纪律同样明确:layer 构建完成之后,不得再变更 process.envFlag

五、Per-Instance 状态:InstanceState 与 ScopedCache

两个同时打开的目录不应共享同一份服务状态时,使用 instance-state.tsInstanceState。源码揭示了其完整机制:

// packages/opencode/src/effect/instance-state.ts (节选)
export const make = <A, E = never, R = never>(
  init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, 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)   // 以当前实例目录为 key
        }),
    })

    const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
    yield* Effect.addFinalizer(() => Effect.sync(off))
    return { [TypeId]: TypeId, cache }
  })
  • 底层是 ScopedCache以目录(directory)为 key,容量无限(instance-state.ts#L26-L45);
  • registerDisposer 把目录卸载事件接到 ScopedCache.invalidate,因此实例卸载时状态自动失效并执行其 finalizer
  • InstanceState.get(state) 用当前 InstanceRefctx.directory 作为 key 取状态(instance-state.ts#L47-L50)。

初始化器中应放置什么:指南要求把订阅、finalizer、scoped 后台任务全部放进 InstanceState.make(...) 的初始化器内,典型形态:

const cache =
  yield *
  InstanceState.make<State>(
    Effect.fn("Foo.state")(function* () {
      const bus = yield* Bus.Service

      yield* bus.subscribeAll().pipe(
        Stream.runForEach((event) => handleEvent(event)),
        Effect.forkScoped,          // 订阅随实例 scope 关闭
      )

      yield* Effect.acquireRelease(openResource, closeResource)  // 资源随实例释放

      return yield* loadInitialState()
    }),
  )

两条禁令:

  • 不要在 InstanceState 之上叠加 started 布尔标志ScopedCache 的 lookup 语义天然保证"每个 key 只初始化一次 + 并发去重",额外的 started 标志是重复实现且容易在失效/重建后失步;
  • 不要为了让 init() 非阻塞而在 InstanceState.make(...) 内部 fork。部分初始化状态被提前返回会造成读者看到不一致的中间态;正确做法是在调用方/bootstrap 边界 fork。

六、错误规范:预期失败走错误通道,Defect 留给真正的 Bug

指南把错误纪律压缩为一条核心句:预期领域失败属于 Effect 错误通道;Defect 只留给 bug、不可能状态和最终未知边界兜底。新预期错误用 Schema.TaggedErrorClass

export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
  sessionID: SessionID,
  message: Schema.String,
}) {}

export type Error = Storage.Error | SessionBusyError

export interface Interface {
  readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}

规则清单(完整继承自指南):

  • 新的预期领域错误一律 Schema.TaggedErrorClass
  • 服务模块导出领域级 Error 联合类型,并写进方法签名(如上例 Effect<Info, Error>),使调用方在编译期就能看到全部可能失败;
  • Effect.gen / Effect.fn 中,直接预期失败优先写 yield* new MyError(...)
  • 未知 cause 字段用 Schema.Defect
  • 外部失败翻译使用 Effect.try(...)Effect.tryPromise(...)Effect.mapErrorEffect.catchTagEffect.catchTags
  • 不得用 Effect.die(...) 表达用户、IO、校验、资源缺失、鉴权、provider 或忙状态(busy-state)失败——这些全是可预期的领域事件。

配套的路线图 todo.md 记录了落地进度:storageworktreeprovider/auth 已完成 NamedError.create(...) / Effect.dieTaggedErrorClass 的转换(例如 ERR-1storage.ts 引入类型化 NotFoundError),session 的 HTTP 错误契约也已收紧,"busy-session 映射集中化"正是 SessionBusyError 这类错误的直接产物。

七、HTTP 错误边界:服务模块保持 HTTP 无知

领域服务模块不得导入 HTTP 状态码、HttpApiErrorHttpServerResponse 或路由专属错误 schema。HTTP handler 负责把服务错误翻译成端点声明的公开错误 schema:

  • 一次性映射保持内联(inline);
  • 只有同一翻译重复出现时才提取微小的共享 helper;
  • 不得把通用中间件变成领域错误注册表——中间件只处理横切关注点和最终的未知 defect 兜底;
  • 保留 legacy 公开 wire 形态(如 { name, data }),直到一次刻意的破坏性 API 变更。

这条边界解释了 todo.md 中 P0 的 HTTP 工作路径:目标正是让 middleware/error.ts 一类的"按错误名猜状态码"的中间件持续收缩,让每个路由组显式声明其公开错误契约。

八、Schema 规范与有意的边界

Effect Schema 是唯一事实来源(source of truth):

  • 有明确身份的导出数据对象用 Schema.Class
  • 局部形状与简单嵌套对象用 Schema.Struct
  • 单值 ID 用 Schema.brand
  • 复用命名 refinement,不要重复拼写约束
  • 边界处优先窄化 helper,而非通用的 Schema-to-Zod 桥。

指南同时列出了三处有意的例外边界,避免过度统一:

  1. 公开插件工具仍通过 tool.schema = z 暴露 Zod;
  2. 工具参数 JSON Schema 通过工具专属 helper 生成;
  3. 公开配置与 TUI schema 通过 schema 脚本生成。

九、首选服务:Effect 代码内不碰原始平台 API

在 effectified 代码中,yield 既有服务而非临时下沉到平台 API。对照表如下(左为禁用,右为首选):

场景 避免 首选
应用文件 IO 原始 fs/promises FSUtil.Service
子进程 直接 ChildProcessSpawner.spawn / 遗留进程 helper AppProcess.Service
HTTP 请求 Effect 代码内原始 fetch HttpClient.HttpClient
路径/配置/时钟/时间 平台 API Path.PathConfigClockDateTime
回调式 API 手写 promise 包装 Effect.callback
返回空 Effect.succeed(undefined) Effect.void
并发共享同一在途计算 各自重算 Effect.cached

后台循环的标准形态:Effect.repeatEffect.schedule,配合 Effect.forkScoped 挂在拥有它的 layer/state scope 上,随 scope 关闭自动回收——这与 InstanceState 初始化器中的 Effect.forkScoped 是同一套生命周期纪律。

十、Promise 与 ALS 桥接:EffectBridge

bridge.tsEffectBridge唯一被认可的、需要在 Promise/回调互操作中保留实例/工作区上下文的 helper。源码展示了它的四个 API 与上下文捕获机制:

// packages/opencode/src/effect/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 }
}

export function make(): Effect.Effect<Shape> {
  return Effect.gen(function* () {
    const captured = captureSync()
    const instance = (yield* InstanceRef) ?? captured.instance
    const workspace = (yield* WorkspaceRef) ?? captured.workspace
    const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
      attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>, { instance, workspace })
    return {
      promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
        restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))),
      fork: /* 同上,runFork */,
      run: /* 基于 Effect.callback 的 fiber 化执行 */,
      bind: /* 同步回调包装 */,
    } satisfies Shape
  })
}

要点:

  • make() 在当前 fiber 上下文捕获 InstanceRef / WorkspaceRef,之后通过 attachWith 把这两个 ref 重新注入经桥运行的 effect,并用 restoreWorkspace 恢复 WorkspaceContext 的 AsyncLocalStorage(bridge.ts#L54-L82);
  • fromPromise(fn)bridge.ts#L48-L52)是 Effect.promise 的上下文安全变体:先恢复 workspace ALS 再执行 promise 工厂;
  • 纪律:普通 JS 回调如需要实例数据,应显式接收参数,而不是指望 ALS 隐式穿透。

十一、测试模式:testEffect 与三种 runner

详细规则在 EFFECT_TEST_MIGRATION.md,核心模式(指南原文):

const it = testEffect(Layer.mergeAll(MyService.defaultLayer))

describe("my service", () => {
  it.instance("does the thing", () =>
    Effect.gen(function* () {
      const svc = yield* MyService.Service
      expect(yield* svc.run()).toEqual("ok")
    }),
  )
})

三种 runner 按被测行为选择:

  • it.effect(...):纯 Effect 行为,配 TestClock / TestConsole
  • it.instance(...):需要一个 scoped opencode 实例的服务行为(测试目录形如 opencode-test-*);
  • it.live(...):真实计时器、文件系统 mtime、子进程、git、锁等 live 集成行为。

规则清单(完整继承):

  • 优先使用 test/fixture/fixture.ts 的 Effect 感知 fixtures(TestInstancetmpdirScopedprovideInstanceprovideTmpdirInstancedisposeAllInstances);
  • 避免 sleep,等待真实事件或确定性状态转移;
  • layer 构建后不得再变更可变的 process.envFlag 或模块全局;
  • 部分服务桩用 Layer.mock(缺失方法被误调用时应响亮失败);
  • 避免自定义 ManagedRuntimeattach(...) 或临时 run(...) 测试包装器

迁移文档还明确列出应清除的反模式:test(..., async () => Effect.runPromise(...))、只提供 layer 的局部 run(...)/load(...) 包装器、Promise.withResolvers / Bun.sleep 同步、对 Effect 失败套 Promise try/catch、以及在测试文件里自建 ManagedRuntime.make(...)。文档中给出的现成范例包括 test/effect/instance-state.test.ts(目录作用域、实例切换、释放与并发)与 test/bus/bus-effect.test.tsDeferred、stream、scoped fiber),可作为模式参照。

十二、验证方式

packages/opencode 目录下执行:

bun run typecheck
bun run test -- path/to/test.ts

指南特别强调:不要从仓库根目录跑测试——仓库对这一点设有守护(guard)。做迁移时,聚焦目标测试文件加一次 typecheck 即可,这也是 EFFECT_TEST_MIGRATION.md "转换配方" 最后一步的要求。

小结

这套规范的主线可以概括为四条:结构上一模块一服务、扁平导出加自命名空间;运行时上一切经 AppRuntime,实例上下文经 InstanceRef/WorkspaceRef 显式传递,跨边界用 attach/EffectBridge 恢复;状态上按目录 InstanceState 隔离,生命周期全部交给 scope 与 finalizer,不叠加手写标志;失败上预期错误类型化进错误通道,Effect.die 与 HTTP 知识都被挡在领域边界之外。按 todo.md 的优先级逐文件推进,即可让 packages/opencode 持续向这个目标形态收敛。

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