OpenRig 测试系统 ROUTING 契约解析:51-03 场景集的能力路由、依赖落地状态与十一大缺陷类映射
OpenRig 测试系统 ROUTING 契约解析:51-03 场景集的能力路由、依赖落地状态与十一大缺陷类映射
导读
本文围绕 packages/test-system/ROUTING.md 展开,它是 OpenRig 容器化测试系统(release-0.5.1 mission)中 51-03 场景集 的能力路由清单:十一份场景 YAML 所依赖的 stub 运行时、场景格式与 runner、事务化执行收口等能力,究竟由 51-01 / 51-02 / 51-06 哪个切片提供、当前是否已落地(LANDED / OPEN / RESOLVED)。读完本文,你将掌握"路由而非修补(route, never shim)"这一测试基建纪律的完整语义、每条 R- 依赖项对应的仓库源码与场景证据,以及如何按 51-02 锁定格式阅读和驱动这些场景文件。
背景:51-03 场景集为什么需要一份 ROUTING 清单
ROUTING.md 开篇就给出了它的法律地位:Per 51-03 mini-req 2 + README Risks 1/2/3/5: any missing runner verb, stub behavior, or env-helper capability is a 51-01/51-02 gap. It ROUTES to that slice's owner — it is never patched, shimmed, or worked around inside 51-03. 也就是说,51-03 只负责"按锁定格式写出场景",凡是 runner 动词、stub 行为、环境辅助能力缺失,一律登记为上游切片的缺口(gap),绝不在这套场景集内部打补丁、垫 shim 或用 workaround 绕过去。每个条目都按锁定/裁决后的形状(locked/ruled shape)编写,保证上游依赖落地时可以"即插即用"。
与之配套的是 packages/test-system/README.md 中定义的种子契约(seed contract):每个场景都携带 seed_regression: {class: …} 步骤,并声明其 SEED 定义——该缺陷类被播种后必然导致哪些 expect 腿变红。一个场景的验收是"配对":在已发布 tip 上 GREEN + 在播种运行上 RED,且 runner 的 diff 必须点名该缺陷类。无法被证明会 RED 的场景不算交付。这一纪律是本文全部 R- 条目背后"诚实状态报告"的根源。
总原则:能力缺口只路由、不修补,未绑定动作必须大声失败
从源码看,这条纪律被实现成了硬性错误类型而非静默跳过:
- scenario-real-deps.ts 定义了
UnboundActionError:当 runner 执行到 v1 没有运行时绑定的动作动词(emit/restore/mutate/policy等)时,FAILS LOUD,绝不伪造一次调用。其注释明确说明"a green that means nothing"比失败更糟。 - 同一文件中,
seed_regression必须显式注入故障控制器(opts.seedRegression),没有控制器就大声失败——"a declaration alone never counts as a seeded test"。 - scenario-pipeline.ts 定义了
ScenarioModeUnsupportedError与ScenarioPreconditionError,分别对应容器模式拒绝env.stub_scripts、env.queue前置条件失败等场景,全部是命名清晰的异常而非静默降级。
这套"未绑定 → 大声失败"的设计,正是 ROUTING.md 中每个 LANDED/OPEN 状态能够被信任的前提:状态是实测的,不是声明的。
51-01:stub 运行时能力(R-01 ~ R-03)
51-01 是 stub 运行时切片。ROUTING.md 把该切片的缺口与已落地项分成三条:
R-01:emit 行为词表(compaction / slow_output / mid_turn_death / restore)——启动脚本已落地,step-time 仍未绑定
两个携带 emit 的场景——#6 compaction 与 #8 slow_output——消费 emit.behavior ∈ {compaction, slow_output, mid_turn_death, restore} 这锁定四词表。注意 #2 queue-baton 用的是 env.queue 前置条件加 daemon: {op: restart},不是 emit(ROUTING.md 特意做了计数纠正,并指向 scripts/README.md)。
当前状态的关键区分在于:
- 四个 emit 行为已经落地,但它们只能从每席启动脚本(stub-script.ts 执行、由 stub-runner.ts 驱动)运行;
- 在 step-time 执行
emit会抛出UnboundActionError,所以 #6/#8 能通过格式校验(PARSE),但会停在第一个emit步骤上; usage_limit被刻意排除(真实运行时专用,stub 拓扑中必须校验失败,任何场景都不允许命名它)。
从源码印证:UnboundActionError 对 emit 专门写了 WHY——stub runner 没有输入通道(无 stdin、无 socket、无 CLI/daemon 路由可以触发行为),行为只从启动时读取的 LAUNCH 脚本执行,因此 step-time emit 在没有 51-01 源码工作前无法被诚实绑定(已路由到 51-01 backlog)。模拟一个"看起来绿"的绑定是毫无意义的。
R-02:场景解析的 stub 脚本(scripted-response 契约)——已落地(host 模式)
场景通过 env.stub_scripts 把席位映射到脚本路径(相对场景文件解析);管线把脚本写到 <seat cwd>/.openrig/stub/script.json,stub 在启动时读取。默认的 come-up-ready 脚本(51-01 内建)已覆盖当前 lifecycle/queue/send 场景,scripts/ 下尚未编写任何行为脚本。
源码侧的三处硬校验:scenario-schema.ts 校验 env.stub_scripts 必须是 <seat> → <script path> 映射且路径非空;scenario-stage.ts 要求每个 key 精确解析到恰好一个 runtime: stub 成员(找不到 / 有歧义 / 指向非 stub 席位 / 两个 key 指向同一席位,都会报错);scenario-pipeline.ts 则明确:容器模式拒绝 env.stub_scripts(ScenarioModeUnsupportedError),因为 per-seat 脚本投递当前不支持该路径。
R-03:stub 席位 ps ctx% 资格(A5 item 5)——暂不启用
目前没有任何场景对 stub 席位断言 ps 的 ctx%,因为这会门控在 A5 ContextMonitor 的 GAP-1/GAP-2 增量上。ROUTING.md 将其标记为"仅在落地后才新增 ctx% 场景",避免提前写入注定红的断言。
51-02:场景格式 + runner + env-helper(R-10 ~ R-17)
51-02 是场景格式与执行器切片,承载了本文最密集的依赖条目。
R-10:runner 与密封 env-helper——已落地
- runner 入口:run-scenarios.mjs,基于 scenario-pipeline.ts(parse → validate → run 三段式)。它只接受场景 YAML 路径参数(带
-前缀的未知 flag 会在执行任何场景前被[REFUSED]拒绝,进程码 2),默认在test/fixtures/scenarios/下找证据场景。 - 密封辅助:hermetic-env.ts,强制 local、清洗环境变量、scratch HOME、fail-closed。runner 只把
HOME/PATH/TERM传给脚手架,环境中的TMUX及一切 daemon 变量会被丢弃而非拒绝,因此即使在 tmux 里运行也安全。 - 运行方式是"产品即真相"(product-is-truth)e2e:
up会在脚手架自有的 tmux server(自设TMUX_TMPDIR)上真实拉起runtime: stub席位,整个运行无法触达操作者的 tmux server;所有断言都通过已发布的rigCLI(真实子进程)读取,绝无伪造观察者。
密封性的负向证据(proof item)正是 helper 本身的:即使用这些场景来演练它。参考 ci/README.md:CI 的 installed-scenario 作业在无特权、断网、只读根、私有可写 /tmp、降权、受限资源(2 CPU / 2GiB 无 swap / 256 PIDs / network-none / 非 root / 只读根 + scratch tmpfs)的容器内运行三连(healthy / lost-baton / healthy again)。
R-11:daemon: {op: sigterm|restart} 步骤动词(#11)——已在 51-02 v1 落地
该动词由 ARCH-RULING-51-09 裁决,落在共享 env-helper 生命周期表面上:单所有者 spawn ⇒ kill/restart,重新 spawn 仍走同一套 forced-local / scratch / fail-closed 保证(在注入时钟下)。dev-planner 的 51-03 review 确认它已进入修订后的 51-02 v1(A2 终稿 28dc80cf / 4180a007),不再是"搭便车过闸"的缺口。源码侧 DAEMON_OPS = <a href="https://link.gitcode.com/i/bad9ccb797555927aa16f444425ab8ae" target="_blank">"sigterm", "restart"] 定义在 [scenario-schema.ts,且校验器明确区分"场景本地 daemon 的生命周期"与"席位级 restart 动词"。
实际用法见 kill-daemon-mid-handoff.yaml:
steps:
- up: {}
- send: {to: dev-qa@dev-pair-stub, text: "execute cleanup task (receipt on disk)"}
- daemon: {op: sigterm} # 执行完成后、收口写入提交前杀掉 daemon
- daemon: {op: restart}
R-12:多 rig up(#9)——开放
#9 需要拉起两个 rig 来证明 ps 的 scope 诚实性。但锁定的 topology: 是场景单数;#9 采用 per-step up: {topology: ./rig-beta-stub.yaml} 覆盖形式来命名第二 rig。问题在于 runner 忽略该覆盖:每个 up 都只对场景顶层 topology 执行 rig up(见 scenario-real-deps.ts 的 verb→argv 映射)。因此它被路由为 51-02 的规格问题,#9 按覆盖形式编写等待落地。当前 ps-scope-honesty.yaml 里的 - up: {topology: ./rig-beta-stub.yaml} 是"第二 rig 必须真的起来,泄漏捕获才真实"的核心依赖。
R-13:equals 跨表面归一化器(#10)——格式已落地,#10 尚未转换
声明式归一化器以 per-surface 投影映射发布:equals: {<surface>: {pluck, path, rig}}(见 scenario-normalizer.ts),这是校验器接受的唯一形式。#10 仍在使用裸表面列表,会被校验器以 EQUALS_NOT_DECLARATIVE 拒绝。实际场景 one-view-state-after-mutation-storm.yaml 展示了正确的锚定写法:schema 要求每个 expect 有名义 surface,所以用 surface: ps 作锚、equals 是被比较表面的列表(不是对象):
- expect:
surface: ps
within: 30s
equals:
- tui_socket
- ps
- queue
该场景还演示了 env.tui: true 前置条件:tui_socket 的 expect 要求 TUI 显式启用——管线只在声明时供给已发布的 TUI(隔离 tmux server + scaffold socket),读表面永远不会与一个从未监听的 socket 竞态。
R-14:env.pre_existing_tmux 密封 fixture(#5)——开放
这是最难的环境 fixture(在 scratch scaffold 内预先存在一个 tmux server)。目前没有任何 runner 代码读取 env.pre_existing_tmux:校验器接受该 key、管线忽略它,所以 #5 是"无 fixture 地运行"而不是阻塞。home-divergence-preseed-visible.yaml 声明了 env: {pre_existing_tmux: true},其断言前提是 GAP-7 已合入 main(d0668d5f:preserve Codex home for daemon),因此 GREEN 腿是确定性单结果——preseed 到达席位(expect pane contains "trust reached")。
R-15:析取 expect any_of(#5)——已裁决:不采纳
#5 最初绑定的是"两个诚实结果落其一"(preseed 到达席位 或 失败在 stream 上可见),但锁定的 expect 是单匹配,any_of 步骤会以 UNKNOWN_STEP_VERB 校验失败。裁决结果是 validator 拒绝 any_of 是正确的并予以保留:#5 改为断言一个结果(expect pane 含 trust reached),把"失败可见"腿留给其 seed_regression 腿(class gap7-home-divergence)去钉住——播种后 preseed 失败必须在 stream 上可见,绝不静默。这与 FLAG-3 确定性原则一致:析取裁决会接受一次竞态。
R-16:expect 上的 ps scope 选择器(#9)——开放
断言 current-rig 默认 / -A / --rig <name> 需要 runner 参数化已发布的 rig ps 读取。当前 ps 读取器只跑 rig ps --json、不接受选择器,所以 #9 只断言默认(current-rig)腿,-A / --rig 腿作为注释保留,直到选择器落地。ps-scope-honesty.yaml 中明确:若现在就断言带选择器的腿,daemon 可能在投影里静默丢弃选择器,造成假 GREEN(false green)——这正是"接受但不兑现(accept-and-drop)"类缺陷的规避。核心泄漏捕获(rig-beta 已 up 但必须缺席 rig-alpha 的默认视图)目前可表达。
R-17:交接 / 队列完成动作(#11)——开放
忠实复现真实事故的 handoff-and-complete 收口,可能需要一个超越 send 的队列完成动作。#11 通过 send 驱动已执行的工作,但 send 跑的是 rig send,不会创建队列项,且语法中没有队列动作动词:队列项只能来自 env.queue 前置条件(在第一个 up 之后应用)。由于 #11 未声明 env.queue,它的第一个 expect queue 没有可匹配的项。这一前提性问题(send 创建 stream 项而非 queue 项)被显式标注为 R-17 路由项。
51-06:事务化执行收口——#11 的 GREEN 门(R-20)
R-20 是本文最有戏剧性的一条: #11 kill-daemon-mid-handoff 是 RED-FIRST(先红后绿) 场景,它的产品侧 GREEN 门是 51-06 的事务化执行收口 + executed-unclosed reconcile(POOLED,尚未合入 main,在 13e26355 处已确认不在 main)。在 51-06 落地前,该场景的运行被记录为 expected-RED,绝不 massaged(绝不粉饰成绿)。
ROUTING.md 还专门做了一次纠正(supersedes an earlier acting-orch feed):bfbc2182 是 90840bcb 的 response-integrity CLI 提交、4cd2c313 是 slow-op-recorder 的 drain latch——两者都不触及 queue/closure 代码,与 #11 正交,都不能满足其门禁。同时澄清:daemon SIGTERM/restart 只是 daemon: {op} 动词(R-11);跨 SIGTERM 的 queue-claim 持久性是 SQLite 基线能力,不是切片门禁。
事故原型(2026-08-05,pm-lead ruling 6eb9166a):一个通过门禁、guard-cleared 的分支清理正确执行并在磁盘上收到回执,但 daemon 在队列收口落地前死亡,导致后续读者把已完成的工作误读为带外事故约 5 小时。因此该场景断言的是已发布表面上可见的歧义(queue 诚实 + stream 上可见 executed_unclosed_divergence),绝不断言磁盘回执(那不是已发布表面):
- expect:
surface: queue
within: 30s
match:
items:
- destination: qa
executed: true
closure: reconciled
- expect:
surface: stream
within: 30s
match:
events:
- kind: executed_unclosed_divergence
visible: true
其余阻塞项清单(R-17 的 setup、post-restart 的 queue/stream 断言形状)也在 R-20 条目中如实登记。
落地映射:十一大场景 × R- 依赖 × 仓库证据
| # | 场景文件 | 缺陷类 | 核心 R- 依赖 | 当前状态 |
|---|---|---|---|---|
| #1 | clean-lifecycle-no-residue.yaml | install/up/down residue | R-10 | GREEN through bound steps;空集断言受 F-#1 限制(match: [] 是平凡真,不表达空) |
| #2 | queue-baton-survives-restart.yaml | baton drop / stale-activity | R-10 | 唯一进 CI 的场景(见 ci/README.md) |
| #3 | locked-restore-never-permissive.yaml | fail-open restore 反转 | R-10 | policy/restore 动词未绑定(UnboundActionError) |
| #4 | policy-posture-survives-structural-edit.yaml | Seam-B posture loss | R-10 | policy/mutate 动词未绑定 |
| #5 | home-divergence-preseed-visible.yaml | GAP-7 HOME divergence | R-14, R-10 | any_of 已裁决;pre_existing_tmux fixture 开放 |
| #6 | compaction-restore-resumes-role.yaml | wrong-seat restore | R-01, R-10 | emit 行为已落地但仅限启动脚本;step-time 未绑定 |
| #7 | send-renders-in-addressed-pane.yaml | receipt ≠ render | R-10 | 表面解析成功,内容标记路由到 51-01 stub 行为 |
| #8 | stream-emit-durable-replay-live.yaml | stream 不持久 | R-01, R-10 | emit 未绑定;stream-vs-activity 表面语义待裁决(F-#8) |
| #9 | ps-scope-honesty.yaml | across-rigs leak | R-12, R-16 | 默认腿 LIVE;选择器腿 R-16 延迟;多 rig up 开放 |
| #10 | one-view-state-after-mutation-storm.yaml | view-state divergence | R-13 | 格式侧已适配;normalizer 映射待 51-03 定型 |
| #11 | kill-daemon-mid-handoff.yaml | executed-unclosed | R-11, R-17, R-20 | RED-FIRST;51-06 门禁前记录 expected-RED |
场景集还包含同族补充场景:down-stops-every-seat.yaml(down 后 ps 必须报 status: stopped、runningCount: 0、isArchived: false)与 send-verify-means-rendered.yaml(send --verify 必须意味着"已渲染"而非"已回执")。
路径收尾与目录布局:co-located 自包含投放
ROUTING.md 的 Path finalization 段说明这些文件以自包含投放体(self-contained drop)活在 packages/test-system/ 下,具体树位置由 51-02 构建最终决定,投放布局是 co-located:
scenarios/携带场景 YAML 及其旁的 fixture 资产(topologies、agents/、culture.md);scripts/在其旁;- 原先独立的
fixtures/目录已被吸收进scenarios/。
从目录实况看正是如此:scenarios/ 下同时躺着 13 份场景 YAML、6 份 stub 拓扑(dev-pair-stub.yaml、solo-stub.yaml、rig-alpha-stub.yaml、rig-beta-stub.yaml、mutation-pod-stub.yaml、policy-seat-stub.yaml)、agents/(impl/lead/qa/worker)与 culture.md。拓扑 fixture 采用 51-02 锁定形式:version: "0.2"、culture_file: culture.md、per-pod edges、无顶层 edges,席位 runtime: stub、cwd: .,规范席位会话为 <pod>-<member>@<name>。
附:从源码验证的已发布读表面形状
ROUTING.md 的依赖条目都指向"已发布可观测表面",其真实 JSON 形状在 FORMAT-CONFORMANCE-NOTES.md 中 verify-at-source 定案,是编写任何 expect 前的必读:
- ps:
rig ps --json是按 rig 的裸数组 PsEntry{rigId,name,nodeCount,runningCount,activeCount,hasWorkCount,status,lifecycleState,uptime,…}——不是按席位(席位走--nodes的 NodeEntry 端点); - queue:
rig queue list --json是裸数组 QueueItem[]{qitemId,state,destinationSession,…},compact 模式会省略 body/summary,需--full/show; - stream:
rig stream list --json是裸数组 StreamItem——注意这是 send/broadcast 协调流,不是 emit 行为写入的 activity/hooks 流(F-#8 的裁决点); - policy_provenance:
rig policy effective --json→{effective:{binding,resolvedScope}|null, posture:"known"|"unknown_posture", hint?}; - pane:capture →
CaptureResult{content},contains即 content 子串; - tui_socket:UNIX socket,
state请求 →{ok,instanceId,state:{screen,drill,filter,viewTab,…}}; - proof:无读表面(FLAG-1,fail-closed
UnboundSurfaceError)——没有任何场景断言它,校验器直接拒绝。
结语:一份"诚实状态"的路由账本
ROUTING.md 的价值不在它记录了哪些能力已落地,而在它如何记录:每个缺口都有归属切片、有裁决依据(arch ruling / 提交哈希 / dev-planner review)、有"落地后即插即用"的锁定形状、有绝不粉饰的运行状态(expected-RED 就如实记为 expected-RED)。对于在 OpenRig 上继续开发测试系统的工程师或 Agent,这份清单与 README.md、FORMAT-CONFORMANCE-NOTES.md 构成三位一体的"读场景必读":前者给能力地图,中者给种子契约与运行诚实度,后者给真实表面形状与格式差异演化史。新场景要做的第一件事,就是先查 ROUTING:依赖是否 LANDED,没落地就按锁定形状写好并路由,而不是就地 shim。