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.Service、Foo.defaultLayer、Foo.Interface一样被命名空间化引用。仓库中大量现存服务已采用此形态,例如 compaction.ts 末尾的export * as SessionCompaction from "./compaction"、instruction.ts 末尾的export * as Instruction from "./instruction"。 - 特例:
src/config目录下保留文件顶部自导出的既有模式。
layer与defaultLayer的分工是关键设计: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/WorkspaceRef并provideService注入,使实例上下文跨边界不丢失。
实践规则:
- 在 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把非正整数收敛为undefined,outputTokenMax与bashDefaultTimeoutMs即采用此模式。
测试侧的标准写法是显式 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.env或Flag。
五、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)用当前InstanceRef的ctx.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.mapError、Effect.catchTag、Effect.catchTags; - 不得用
Effect.die(...)表达用户、IO、校验、资源缺失、鉴权、provider 或忙状态(busy-state)失败——这些全是可预期的领域事件。
配套的路线图 todo.md 记录了落地进度:storage、worktree、provider/auth已完成NamedError.create(...)/Effect.die到TaggedErrorClass的转换(例如ERR-1为 storage.ts 引入类型化NotFoundError),session 的 HTTP 错误契约也已收紧,"busy-session 映射集中化"正是SessionBusyError这类错误的直接产物。
七、HTTP 错误边界:服务模块保持 HTTP 无知
领域服务模块不得导入 HTTP 状态码、HttpApiError、HttpServerResponse或路由专属错误 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 桥。
指南同时列出了三处有意的例外边界,避免过度统一:
- 公开插件工具仍通过
tool.schema = z暴露 Zod; - 工具参数 JSON Schema 通过工具专属 helper 生成;
- 公开配置与 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.Path、Config、Clock、DateTime |
| 回调式 API | 手写 promise 包装 | Effect.callback |
| 返回空 | Effect.succeed(undefined) | Effect.void |
| 并发共享同一在途计算 | 各自重算 | Effect.cached |
后台循环的标准形态:Effect.repeat或Effect.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(
TestInstance、tmpdirScoped、provideInstance、provideTmpdirInstance、disposeAllInstances); - 避免 sleep,等待真实事件或确定性状态转移;
- layer 构建后不得再变更可变的
process.env、Flag或模块全局; - 部分服务桩用
Layer.mock(缺失方法被误调用时应响亮失败); - 避免自定义
ManagedRuntime、attach(...)或临时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.ts(Deferred、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),仅供参考