opencode Effect 编程规范实战指南:Service 形态、运行时边界、InstanceState 与测试模式
本文基于 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 global memoMap"的实现,意味着同一 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 失败套 Promise try/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 持续向这个目标形态收敛。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00