DeepSeek Harness 的运行时不变式:一份写死的「不许出现」清单
先说清楚前提:DeepSeek Harness 的仓库 README 里有一节标题就叫 Developer preview,原文写着它正在快速迭代,并且用全大写强调 THERE WILL BE COMPATIBILITY-BREAKING CHANGES(会有破坏兼容性的变更)。下面提到的字段名、默认值、目录位置,都是仓库快照 47f9438(版本 0.1.0-rc.5)里的样子,随时可能变。我们没有装过它、也没有运行过,全部结论来自读源码和文档。
一个类型检查抓不到的现象
设想这样一个改动:工具执行前要弹一次授权,用户点完往会话日志里追加一条 approval/decided。某次重构之后,某条分支上的 approval/asked 被条件判断吃掉了,日志里只剩下”决定”没有”提问”。
这种 bug 很难查。类型是对的——两个事件各自结构完整;单测也是绿的——单测通常只覆盖单个事件的构造;跑起来不报错,因为没人在运行时检查”这两条必须成对”。等到几周后有人去审计会话日志,才发现对不上。
deepseek-harness 把这类约定从”靠 review 记住”挪成了”写死在代码里”。切入点是一个叫 ctx.invariants 的服务,实现在 packages/runtime-diagnostics/invariants/src/index.ts,子系统文档是 docs/subsystems/invariants.md(同目录有官方中文版 invariants.zh.md)。
检查允许断言什么,是被约定死的
先看边界。根目录 AGENTS.md 的 conventions 一节里有一条原文写着:运行时不变式断言的是自己拥有的关系——检查权威事件流或可变数据,而不是服务/方法是否存在、插件元数据、effect,或者固定的纯函数样例。文档自述的理由是:后面那些属于类型、加载或单元测试该管的事。
这条约定挺关键,它把”运行时不变式”和”断言”这个词的日常用法区分开了。你不能拿它来兜底”这个 service 应该被注入了吧”,只能拿它来盯”这两条事件必须配对""这个状态机不许从 A 直接跳到 C”。
注册表本体:三个字段,两个反直觉的地方
服务的配置只有三个字段,Config 就定义在 src/index.ts 顶部:
/** Runtime invariant selection configured on the service plugin. */
export interface Config {
/** Global switch; defaults to `true`. */
readonly enabled?: boolean
/** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */
readonly package_allowlist?: string[]
/** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */
readonly package_blocklist?: string[]
}
同一个文件里的 InvariantRegistry.Config schema 把默认值写死为 enabled: true、package_allowlist: []、package_blocklist: []。选中逻辑在私有方法 selected() 里,三句话:服务没启用直接 false;allowlist 非空且没有任何 pattern 命中则 false;最后返回”没有任何 blocklist pattern 命中”。blocklist 命中会盖过 allowlist 命中,这一点文档和源码是一致的。
两个容易踩的地方,都在同一段:
第一,条目是用 new RegExp(value) 编译的裸正则源码,不锚定。你写 dsh-agent 会同时命中 @deepseek-ai/dsh-agent 和 @deepseek-ai/dsh-agent-loop。要精确匹配得自己带 ^ 和 $——包 README 的示例里就是这么写的:package_allowlist: ['^@deepseek-ai/dsh-']、package_blocklist: ['^@deepseek-ai/dsh-agent-loop$']。另外 /pattern/flags 这种带斜杠的写法不会被解析,你写进去它就是字面量的一部分。
第二,配置写错不是被静默跳过,而是启动就炸。compilePatterns() 对每个条目依次判:空串或首尾带空白 → 抛 invariants: <field> entries must be non-blank and have no surrounding whitespace;重复条目 → 抛 contains duplicate regex ...;正则编译失败 → 抛 contains invalid regex ... 并把原始异常挂在 cause 上。所以配置里多敲一个空格,服务是起不来的。包 README 里对这一段的表述同样是「空白、带空格、非法或重复的条目会让服务启动失败」——它选的是显式失败而不是忽略无效项,这一点值得在写配置前先知道。
包名是被”保留”的,不是”生效才占坑”
register(packageName, installer) 这个方法有个容易读漏的语义。它先校验包名(空、首尾空白、含任何空白字符都抛错),然后检查 this.registrations 这个 Set 里有没有重名,有就抛 invariants: package "<name>" is already registered。注意这一步发生在过滤之前——也就是说,即使你的 allowlist 把这个包排除掉了,包名依然被占住。
文档里对这个语义的表述是:保留依然成立,因此两个插件绝不可能静默地认领同一个包名。对使用者的直接影响是:你不能靠”反正它被过滤掉了”来复用别人的包名,撞名就是抛异常,而不是安静地共存。
真正被启用的 installer 跑在一个专属的子 Cordis fiber 里。installer.inject 声明这个子 fiber 能访问哪些服务;ctx.plugin(...) 之后会 await child,同步和异步的 installer 都要跑完,注册才算成功。失败路径是 await child.dispose() 之后 registrations.delete(packageName) 再把异常抛出去;正常卸载路径则是返回的 disposer 里 try { await child.dispose() } finally { registrations.delete(packageName) }——两条路都保证子 fiber 释放和名字释放绑在一起。README 对应的说法是「失败会原子地释放子 fiber 与所有权」。
违规长什么样,也是写死的。InvariantError extends Error,带一个稳定的 code = 'INVARIANT',带 packageName 字段,消息前缀是 invariant violated by "<package>": 。所以你在日志里 grep INVARIANT 就能捞到,并且一眼看出是哪个包的约定被破了——注册表自己不 import 任何产品包,归属信息完全靠这个字符串带出来。
回到那条没配对的授权事件
现在去看真正的”不许出现”清单。packages/interaction/user-approval/src/invariant.ts 里的 validateApprovalEvent() 是一串直白的判断:
if (event.type === 'approval/asked') {
if (trace.openTurn === null) fail('approval/asked appended outside any open turn')
if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty')
if (trace.pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`)
return { kind: 'asked', id: event.data.id }
}
approval/decided 那一支就是开头那个现象的答案:if (!trace.pending.has(event.data.id)) fail('approval/decided has no matching approval/asked for id ...')。另外还有一条封闭词表校验,APPROVAL_OUTCOMES 在同文件里写死为 ['allowed-once', 'rejected', 'cancelled', 'unavailable'],出现表外的值就 fail。
同样的套路在 packages/sandbox/sandbox-policy/src/invariant.ts 里更短——整个文件 42 行,核心就一句:sandbox/mode 事件携带的 mode 不在 SANDBOX_MODES 里就 fail。那张表在 packages/sandbox/sandbox-policy/src/session-mode.ts 里,是 ['read-only', 'workspace-write', 'danger-full-access'] 三个值。这里只做词表校验,不涉及任何执行侧的约束;沙箱模式本身怎么落到实际调用上是另一套东西,不能因为有这条检查就认为越权行为会被它拦住。
这两个包的 installer 都用 Object.assign(fn, { inject: ['sessions'] }) 声明依赖,然后同时做两件事:启动时遍历 ctx.sessions.list() 里已有的历史事件补一遍校验,再挂 internal/dispatch 监听新事件。前者对应文档里那句”由会话支撑的配套插件从持久事件重建 baseline”。
219 个包,35 个真检查,标准组合只挂 4 个
数字这块值得单独说,因为很容易看走眼。
pnpm-workspace.yaml 里 packages 那一层的 glob 是 packages/*/*,是两层。packages/ 下我们数出 49 个分组目录(core/、llm/、sandbox/ 这些),但真正的包是 219 个——按 packages/*/*/package.json 数出来的。
这 219 个包,每一个都有 src/invariant.ts,一个不落。这对应文档里那句”发布与注册是穷尽式的”。但里面绝大多数是空的:我们用 No runtime invariant: 这个标记去筛,184 个是空 installer,35 个是可执行的。全部 35 个文件里,按行数出 212 处 fail( 调用点;单文件最多的是 packages/llm/llm-retry/src/invariant.ts,26 处。
这里有一处文档与代码不一致,照实记一笔:包 README(packages/runtime-diagnostics/invariants/README.md)里”当前可执行配套插件”那张表列了 21 个包名(dsh-session、dsh-agent、dsh-llm 等),而我们按源码数出的可执行 installer 是 35 个,表里没出现的包括 dsh-settings、dsh-commands、dsh-schedule、dsh-workspace 等。以实读源码为准;至于差异从哪来,我们不做推断。
最后一个数字是最要紧的:仓库里有 35 个可执行检查,不等于你跑起来有 35 个在跑。 包 README 自述”标准 agent 组合挂载服务和 4 个核心有状态配套插件”,我们在 packages/examples/agent-spine-demo/src/index.ts 里数了一遍:这个文件总共有二十多行 ctx.plugin(...),其中挂 invariant companion 的正好四行——sessionInvariant、agentInvariant、scopeInvariant、agentLoopInvariant,紧跟在 ctx.plugin(InvariantRegistry, config.invariants ?? {}) 后面。剩下 31 个可执行 companion 去哪了?README 的说法是:自定义组合要自己显式添加想检查的 companion,而每个普通 Vitest 拓扑只挂当前测试包的 companion,另有一个穷尽式拓扑挂全部 companion 用来验证注册与释放的接线。它还说得很明确:单独加载服务不会安装任何产品检查,在没有服务时加载 companion 会一直等它声明的 invariants 注入。
空的那 184 个不是敷衍位
我一开始以为空 installer 是占位用的,翻了机械检查脚本才改口。pnpm run verify-package-invariants 在 package.json 里指向 scripts/verify-package-invariants.ts,那只是个 21 行的壳,规则本体在它 import 的 scripts/package-invariants.ts(362 行,import ts from 'typescript' 走 AST 扫源码)。它拒绝的东西包括:
- 空 installer 的声明文本里没有
No runtime invariant:注释——报empty install function must explain why with a "No runtime invariant:" comment - 非空 installer 没接第二个参数——报
install function must accept the bound failure reporter as its second parameter - 接了但整个函数体里没用过它——报
install function must use its bound failure reporter ctx.invariants.register的包名不是本地字符串常量、或者注册的不是自己的包名- 带
@generated标记(原文:companions must be hand-owned) exports["./invariant"]没指向./lib/types/invariant.d.ts和./lib/invariant.js、files没发布lib/invariant.js、@deepseek-ai/dsh-invariants没同时作为workspace:^的 peerDependency 和 devDependency、TypeScript project references 里缺../../runtime-diagnostics/invariants
换句话说,“我这个包没什么可检查的”是一句必须逐包写明理由的话,而不是留空就行。实际写法也确实是逐包不同的,比如 packages/subprocess/subprocess/src/invariant.ts 的注释写的是这个包是无状态的 Service Definition、拥有 spawn-spec/handle 类型而观测归 Service Provider;packages/spill/ 下几个包写的则是”在其归属 seam 之外没有独立的事件序列或可变数据关系”。README 还加了一句约束:当这个包将来获得可变状态或事件协议时,这段说明必须重新审视。
什么时候这套东西帮不上你
包 README 的”已知限制”一节自己列了三条,我把和使用者相关的两条挑出来:
一是正则过滤器在服务生命周期内固定,改它需要走一次普通的 Cordis 插件重载——不是改配置文件就能热生效。二是只做实时观察的 companion 无法重建自身重载之前发生的操作,所以标准组合和测试组合都会在相应操作开始之前就把它们挂上。
还有一类情况根本不该指望它:如果你的问题是”某个 service 没被注入""某个纯函数返回值不对”,按 AGENTS.md 的约定这压根不属于运行时不变式的范畴,写进 companion 会被机械规则或 review 拦掉,该去补类型或单测。
对读者的实际用法大概是这样:当你在 dsh 上扩展一个会往会话日志里追加事件的包时,先去 packages/runtime-diagnostics/invariants/README.md 那张表里看看你要碰的事件有没有 owner 已经在盯;如果你自己新增了一对必须成对出现的事件,那就照 packages/interaction/user-approval/src/invariant.ts 的样子在自己包的 src/invariant.ts 里补一条,fail() 的消息写具体点——那串前缀最后会带着你的包名出现在别人的日志里。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。