opencode Effect 编程规范实战指南:Service 形态、运行时边界、InstanceState 与测试模式
2026/9/7 4:32:40 网站建设 项目流程

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

【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode

本文基于 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 globalmemoMap"的实现,意味着同一 runtime 内各服务的单例语义是一致的。

AppRuntime暴露runSync/runPromise/runPromiseExit/runFork/runCallback/dispose,每个执行入口都经过一层wrap(app-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.ts 的RuntimeFlags.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.ts 的InstanceState。源码揭示了其完整机制:

// 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-1为 storage.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/promisesFSUtil.Service
子进程直接ChildProcessSpawner.spawn/ 遗留进程 helperAppProcess.Service
HTTP 请求Effect 代码内原始fetchHttpClient.HttpClient
路径/配置/时钟/时间平台 APIPath.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.ts 的EffectBridge唯一被认可的、需要在 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 失败套 Promisetry/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持续向这个目标形态收敛。

【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询