DeepSeek Harness 的运行时不变式:一份写死的「不许出现」清单

2026-08-17

先说清楚前提: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: truepackage_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-sessiondsh-agentdsh-llm 等),而我们按源码数出的可执行 installer 是 35 个,表里没出现的包括 dsh-settingsdsh-commandsdsh-scheduledsh-workspace 等。以实读源码为准;至于差异从哪来,我们不做推断。

最后一个数字是最要紧的:仓库里有 35 个可执行检查,不等于你跑起来有 35 个在跑。 包 README 自述”标准 agent 组合挂载服务和 4 个核心有状态配套插件”,我们在 packages/examples/agent-spine-demo/src/index.ts 里数了一遍:这个文件总共有二十多行 ctx.plugin(...),其中挂 invariant companion 的正好四行——sessionInvariantagentInvariantscopeInvariantagentLoopInvariant,紧跟在 ctx.plugin(InvariantRegistry, config.invariants ?? {}) 后面。剩下 31 个可执行 companion 去哪了?README 的说法是:自定义组合要自己显式添加想检查的 companion,而每个普通 Vitest 拓扑只挂当前测试包的 companion,另有一个穷尽式拓扑挂全部 companion 用来验证注册与释放的接线。它还说得很明确:单独加载服务不会安装任何产品检查,在没有服务时加载 companion 会一直等它声明的 invariants 注入。

空的那 184 个不是敷衍位

我一开始以为空 installer 是占位用的,翻了机械检查脚本才改口。pnpm run verify-package-invariantspackage.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.jsfiles 没发布 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 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。