dsh-agent-board 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ // ===== 故障 E2E:目录失效——运行中删模型,取模跳过留痕并落健康候选(task-mvqi3uj4-5)=====
2
+ // 背景:事故类「删模型仍派」——原形:本体板已删除的失效模型钉在卡级特例上还在被派。本文件锁
3
+ // 「卡级特例不豁免存在闸」的端到端回归(真 index.mjs apply(faultCtx) 全循环,fault-e2e-circuit
4
+ // harness 同款:rpc 建卡/配板 → clock.advance(50) 踢 poolCycle → waitFor 收敛)。
5
+ //
6
+ // 机制锚点(lib/dispatch.mjs):
7
+ // · modelDirectory 每次 spawnOneShot 现取不缓存(L162-178):模型从宿主配置删除/改名要当轮生效;
8
+ // · outOfDir 存在闸(L182):目录可得且模型不在目录 → true(配置已失效);dir=null 恒 false;
9
+ // · traceStaleModel 留痕(L187-207):卡 messages 记 kind='stale-model'(⚠️ 配置已失效文案),
10
+ // per 模型+文本去重(L200-202,重派不刷屏)+ 健康表 observedNote 落「配置已失效」分态(L192);
11
+ // · 卡级特例不豁免存在闸(L662-665 注释):候选循环先过 outOfDir 再过 badModels——卡级只豁免
12
+ // 健康闸(连败软闸/熔断/冷却),原形「钉在卡级特例上的失效模型」正是从这里漏走的。
13
+ //
14
+ // 场景(先含后删,正是合法写路径的盲区):故障层 llm 目录初始含 prov/gone + prov/good;卡级
15
+ // model='prov/gone' 走 update-task(合法清单校验 cardModelErr→enumModels→llm 目录在册放行——
16
+ // ⚠️ create-task 不落 model 字段:args.model 仅 update-task 处理(rpc.mjs update-task 句柄),
17
+ // 「先含」的唯一合法写入口)→ 首轮派发前 h.directory.remove('prov/gone')(fault-provider 可变
18
+ // 目录,shrink 即刻生效)→ poolCycle 派发:outOfDir 命中卡级候选 → traceStaleModel 留痕 → 降下一跳:
19
+ // · 无 lane/board 配置 → parent 裸请求(用例①:含重派去重——推进第二轮 poolCycle 仍恰一条留痕);
20
+ // · board workerModel=prov/good → 落健康候选 good(用例②:含健康表 observedNote 分态断言)。
21
+ // 反例(用例③):目录不可得(llm 服务摘走 listProviders 抛错 → modelDirectory 返回 null)→
22
+ // 目录闸整体跳过不误伤(L158-161 保守语义),prov/gone 照常派发、零留痕——锁「不误伤」分支。
23
+ //
24
+ // 断言(对应任务口径):
25
+ // ① spawn 日志无 prov/gone(带卡级特例也被存在闸拦下)——观测点=fault-provider spawnLog
26
+ // (agentOptions 复合串重构面)+ env.runs[].req(真请求对象),不直读内部状态;
27
+ // ② 卡 messages 恰一条 stale-model 留痕(重派不刷屏:第二轮 poolCycle 后仍恰一条,去重 L200-202);
28
+ // ③ 健康表 observedNote 落「配置已失效」分态(get-tasks 透出则断:fallbackModels 使 prov/gone 进入
29
+ // configuredModelIds 公开视图;用例①无 board 配置不透出,按口径「不透出则略」,由用例②承担);
30
+ // ④ 卡最终在替代模型上正常推进到 resolved(零死锁零空转)。
31
+ //
32
+ // 为什么一次性回退开关(workerContinuable=false,fault-e2e-circuit 同款):一次性路径重试恒
33
+ // fresh spawn(runs 留档无 continuable 标记 → resumeTarget 恒空,tryResumeWorker 整段短路),
34
+ // 结算确定性由 run.result 驱动(failWith/resolveWith),重派循环确定性收敛、零续跑干扰。
35
+ // 为什么关探测(probeEnabled=false,仅用例②):主动探测 observeModel 会以「主动探测通过」覆盖
36
+ // observedNote——本场景只测目录闸分态,探测面由 fault-provider.test.mjs 观测面⑤b 单独覆盖。
37
+ // 注意:boom 失败文本刻意避开模型面失败前缀(isModelKillFailure)与冷却嗅探关键词——单纯 worker
38
+ // 失败只 retryCount+1 回 pending,熔断/冷却两张表零污染(本场景只测目录闸,两张表各管各的)。
39
+ import { test } from 'node:test'
40
+ import assert from 'node:assert/strict'
41
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
42
+ import { installFaults } from './helpers/fault-provider.mjs'
43
+ import * as plugin from '../index.mjs'
44
+
45
+ var GONE = 'prov/gone'
46
+ var GOOD = 'prov/good'
47
+ // traceStaleModel 原文(dispatch.mjs L199 / L192 逐字——回归锁文案分态,措辞变更应被断言拦下)
48
+ var STALE_TEXT = '⚠️ 配置已失效:' + GONE + '(已从模型目录移除),本 run 取模跳过'
49
+ var STALE_NOTE = '配置已失效(已从模型目录移除)'
50
+
51
+ // 卡 messages 里的 stale-model 留痕
52
+ function staleMsgs(t) { return ((t && t.messages) || []).filter(function (m) { return m && m.kind === 'stale-model' }) }
53
+
54
+ // 建卡封装(显式 description 避开空描述软提示;pipeline=work 只做不验,验证面不被 Verifier 干扰)
55
+ async function createTask(env, id) {
56
+ var r = await env.rpc('create-task', { sessionId: env.sid, id: id, title: '故障E2E 目录失效 ' + id, description: '目录失效回归用例 ' + id, pipeline: 'work' })
57
+ assert.equal(r.ok, true, 'create-task 成功: ' + id)
58
+ return r.task
59
+ }
60
+ // 先含:卡级 model 特例走合法写路径(update-task cardModelErr → enumModels → llm 目录在册放行)
61
+ async function pinCardModel(env, id) {
62
+ var r = await env.rpc('update-task', { sessionId: env.sid, taskId: id, model: GONE })
63
+ assert.equal(r.ok, true, '先含:目录在册 → update-task model=' + GONE + ' 合法放行(cardModelErr 经 llm 目录)')
64
+ assert.equal(env.task(id).model, GONE, '卡级特例落卡(t.model)')
65
+ }
66
+
67
+ // ===== 用例①:主场景·无 lane/board 配置 → parent 裸请求兜底 + 重派去重 + 正常推进(真 poolCycle 两轮)=====
68
+ test('故障E2E·目录失效主场景:卡级特例先含后删 → 存在闸拦下(spawn 无 prov/gone)+ parent 裸请求 + 重派去重恰一条留痕 + resolved', async () => {
69
+ var env = createMockCtx()
70
+ try {
71
+ // 故障层:llm 目录初始含 prov/gone + prov/good(可变目录,shrink 即刻生效,每次现取不缓存);
72
+ // programs=[] 纯观察面(spawn 逐字委托 + 日志留痕),无故障程序干扰目录闸单线回归
73
+ var h = installFaults(env, { directory: [GONE, GOOD], programs: [] })
74
+ plugin.apply(env.ctx)
75
+ var wc = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerContinuable', value: false })
76
+ assert.equal(wc.ok, true, 'workerContinuable=false(一次性回退开关,重试恒 fresh spawn)')
77
+ await createTask(env, 'task-d1')
78
+ await pinCardModel(env, 'task-d1')
79
+ // ===== 后删:首轮派发前 shrink(目录仍含 prov/good 非空 → outOfDir 闸生效,非保守跳过)=====
80
+ h.directory.remove(GONE)
81
+ assert.equal(h.directory.has(GONE), false, '后删:prov/gone 已从目录移除')
82
+ assert.equal(h.directory.has(GOOD), true, '目录仍非空(prov/good 在册)→ outOfDir 闸生效')
83
+ // ===== 首轮派发:poolCycle → spawnOneShot 现取目录 → outOfDir 命中卡级候选 → 留痕 → parent 裸请求 =====
84
+ await env.clock.advance(50)
85
+ var run1 = await env.waitFor(function () { return env.runs.length >= 1 ? env.runs[0] : null }, '首轮 spawn 发生')
86
+ // ===== ① spawn 日志无 prov/gone:带卡级特例也被存在闸拦下,降 parent 裸请求 =====
87
+ assert.equal(h.spawnLog.length, 1, '首轮恰一次 spawn')
88
+ assert.equal(h.spawnLog[0].model, '', 'spawn 无 agentOptions(parent 裸请求——卡级特例被存在闸拦下)')
89
+ assert.ok(h.spawnLog.every(function (e) { return e.model.indexOf('gone') < 0 }), '①spawn 日志无 prov/gone')
90
+ assert.equal(run1.req.agentOptions, undefined, 'run 真请求不带 agentOptions(parent 裸请求)')
91
+ assert.equal(env.continuables.length, 0, '一次性路径零 continuable 子会话')
92
+ // ===== ② 卡 messages 恰一条 stale-model 留痕(traceStaleModel 原文)=====
93
+ // 落卡与 claimedBy 换真实 run id 是两笔写——waitFor 等到 claimedBy 换完(与 fault-provider 缺省①同口径)
94
+ var t = await env.waitFor(function () {
95
+ var x = env.task('task-d1')
96
+ return x && x.status === 'in-progress' && x.claimedBy === run1.id && staleMsgs(x).length >= 1 ? x : null
97
+ }, 'stale-model 留痕落卡且 claimedBy=run id')
98
+ assert.equal(staleMsgs(t).length, 1, '恰一条 stale-model 留痕')
99
+ assert.equal(staleMsgs(t)[0].text, STALE_TEXT, '留痕=⚠️ 配置已失效文案(traceStaleModel L199 原文)')
100
+ // 等链尾:失败臂状态推进(settleWorker)与 run 留档关账(settleRunRecord)是两次持锁写——
101
+ // 只等状态会撞上「pending 已落盘而 runs[0] 还停在 running」的窗口(fault-e2e-circuit 同口径)
102
+ run1.failWith('boom')
103
+ t = await env.waitFor(function () {
104
+ var x = env.task('task-d1')
105
+ return x && x.status === 'pending' && x.retryCount === 1 && x.runs && x.runs[0] && x.runs[0].outcome === 'timeout/error' ? x : null
106
+ }, '首轮失败结算:boom 单纯重试(熔断/冷却零污染)回 pending 且 run 关账 timeout/error')
107
+ assert.equal(t.runs[0].modelSource, 'parent', '取模链降到底:modelSource=parent 随 run 留档')
108
+ // ===== ②续段:重派不刷屏(per 模型+文本去重 L200-202)=====
109
+ // 失败臂 kickCycle → 第二轮 poolCycle → 卡级候选再次 outOfDir → traceStaleModel 去重(同文本零改动)
110
+ await env.clock.advance(50)
111
+ var run2 = await env.waitFor(function () { return env.runs.length >= 2 ? env.runs[1] : null }, '第二轮重派 spawn(fresh,模型仍目录外)')
112
+ assert.equal(h.spawnLog.length, 2, '第二轮恰一次 spawn')
113
+ assert.equal(h.spawnLog[1].model, '', '重派同样降 parent 裸请求(卡级特例仍被存在闸拦下)')
114
+ t = env.task('task-d1')
115
+ assert.equal(staleMsgs(t).length, 1, '②重派后仍恰一条 stale-model(per 模型+文本去重 L200-202,重派不刷屏)')
116
+ assert.ok(!t.messages.some(function (m) { return m.kind === 'fallback' || m.kind === 'cooldown' }), '零 fallback/cooldown 留痕(本场景只测目录闸)')
117
+ // ===== ④ 卡在替代模型(parent 裸请求)上正常推进:零死锁零空转 =====
118
+ run2.resolveWith('## 开发描述\n目录失效回归完成')
119
+ t = await env.waitFor(function () {
120
+ var x = env.task('task-d1')
121
+ return x && x.status === 'resolved' && x.runs && x.runs[1] && x.runs[1].outcome === 'completed' ? x : null
122
+ }, '④替代模型上推进到 resolved 且 run 关账 completed(零死锁零空转)')
123
+ assert.equal(t.runs.length, 2, '两轮各一条 run 留档')
124
+ assert.ok(t.runs.every(function (r) { return r.model === '' && r.modelSource === 'parent' }), '两轮全 parent 裸请求(model=父模型空串、modelSource=parent)')
125
+ } finally { await env.cleanup() }
126
+ })
127
+
128
+ // ===== 用例②:主场景·board workerModel=prov/good → 落健康候选 + 健康表 observedNote「配置已失效」分态 =====
129
+ test('故障E2E·目录失效主场景②:board workerModel=prov/good 落到 good(spawn 无 prov/gone)+ 健康表 observedNote 落「配置已失效」分态 + resolved', async () => {
130
+ var env = createMockCtx()
131
+ try {
132
+ var h = installFaults(env, { directory: [GONE, GOOD], programs: [] })
133
+ plugin.apply(env.ctx)
134
+ // 板级配置(目录在册放行=先含的一部分):workerModel=prov/good + fallbackModels=[prov/gone]——
135
+ // 备用链带失效模型是生产常见形态;同时让 prov/gone 进入 get-tasks 公开健康视图的
136
+ // configuredModelIds(modelHealthView 只透出板级配置模型),③分态断言才有观测面
137
+ var wm = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerModel', value: GOOD })
138
+ assert.equal(wm.ok, true, 'workerModel=prov/good 目录在册放行')
139
+ var fb = await env.rpc('set-board-config', { sessionId: env.sid, key: 'fallbackModels', value: [GONE] })
140
+ assert.equal(fb.ok, true, 'fallbackModels=[prov/gone] 目录在册放行(备用链带失效模型=生产常见形态)')
141
+ var wc = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerContinuable', value: false })
142
+ assert.equal(wc.ok, true, 'workerContinuable=false(一次性面)')
143
+ var pe = await env.rpc('set-board-config', { sessionId: env.sid, key: 'probeEnabled', value: false })
144
+ assert.equal(pe.ok, true, 'probeEnabled=false(探测会以「主动探测通过」覆盖 observedNote,关掉聚焦目录闸分态)')
145
+ await createTask(env, 'task-d2')
146
+ await pinCardModel(env, 'task-d2')
147
+ h.directory.remove(GONE)
148
+ assert.equal(h.directory.has(GONE), false, '后删:prov/gone 已移除')
149
+ // 首轮派发:cands=[card prov/gone, board prov/good] → mi=0 outOfDir 命中 → 留痕 → continue →
150
+ // mi=1 prov/good 目录内在册且健康 → 落 board 候选(不经 fallback 链:无熔断/冷却命中)
151
+ await env.clock.advance(50)
152
+ var run1 = await env.waitFor(function () { return env.runs.length >= 1 ? env.runs[0] : null }, 'spawn 发生')
153
+ // ===== ① spawn 落健康候选 prov/good,无 prov/gone =====
154
+ assert.equal(h.spawnLog.length, 1, '恰一次 spawn')
155
+ assert.equal(h.spawnLog[0].model, GOOD, '落 board 候选 prov/good')
156
+ assert.ok(h.spawnLog.every(function (e) { return e.model.indexOf('gone') < 0 }), '①spawn 日志无 prov/gone(带卡级特例也被存在闸拦下)')
157
+ assert.deepEqual(run1.req.agentOptions, { provider: 'prov', model: 'good' }, 'run 真请求带 agentOptions={provider:prov,model:good}')
158
+ // ===== ② 恰一条 stale-model 留痕(card 候选被闸拦下)=====
159
+ var t = await env.waitFor(function () {
160
+ var x = env.task('task-d2')
161
+ return x && x.status === 'in-progress' && x.claimedBy === run1.id && staleMsgs(x).length >= 1 ? x : null
162
+ }, 'stale-model 留痕落卡且 claimedBy=run id')
163
+ assert.equal(staleMsgs(t).length, 1, '恰一条 stale-model 留痕')
164
+ assert.equal(staleMsgs(t)[0].text, STALE_TEXT, '留痕=⚠️ 配置已失效文案(traceStaleModel L199 原文)')
165
+ assert.ok(!t.messages.some(function (m) { return m.kind === 'fallback' }), '零 fallback 留痕(目录闸直落 board 候选,不经熔断/冷却切换分支)')
166
+ // ===== ③ 健康表 observedNote 落「配置已失效」分态(get-tasks 透出则断)=====
167
+ var gt = await env.rpc('get-tasks', { sessionId: env.sid })
168
+ var mh = gt.modelHealth || (gt.data && gt.data.modelHealth) || {}
169
+ assert.ok(mh[GONE], 'prov/gone 在公开健康视图(fallbackModels 进 configuredModelIds)')
170
+ assert.equal(mh[GONE].note, STALE_NOTE, '③observedNote 落「配置已失效」分态(traceStaleModel L192 原文)')
171
+ // ===== ④ 卡在替代模型 prov/good 上正常推进(零死锁零空转)=====
172
+ run1.resolveWith('## 开发描述\n目录失效回归完成')
173
+ t = await env.waitFor(function () {
174
+ var x = env.task('task-d2')
175
+ return x && x.status === 'resolved' && x.runs && x.runs[0] && x.runs[0].outcome === 'completed' ? x : null
176
+ }, '④替代模型上推进到 resolved 且 run 关账 completed')
177
+ assert.equal(t.runs[0].model, GOOD, 'run 落 prov/good')
178
+ assert.equal(t.runs[0].modelSource, 'board', 'modelSource=board 随 run 留档')
179
+ } finally { await env.cleanup() }
180
+ })
181
+
182
+ // ===== 用例③:反例·目录不可得(llm 服务摘走 listProviders 抛错)→ 目录闸整体跳过不误伤 =====
183
+ // modelDirectory 的保守语义(L158-161;L167 catch → null):目录不可得时 outOfDir 恒 false(L182),
184
+ // 目录闸整体跳过,只留连败软闸生效——prov/gone 照常派发、零 stale-model 留痕(不误伤不误报)。
185
+ test('故障E2E·目录失效反例:目录不可得 → 目录闸整体跳过不误伤,prov/gone 照常派发(零留痕)+ 正常推进', async () => {
186
+ var env = createMockCtx()
187
+ try {
188
+ var h = installFaults(env, { directory: [GONE, GOOD], programs: [] })
189
+ plugin.apply(env.ctx)
190
+ var wc = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerContinuable', value: false })
191
+ assert.equal(wc.ok, true, 'workerContinuable=false(一次性面)')
192
+ await createTask(env, 'task-d3')
193
+ await pinCardModel(env, 'task-d3') // 先含(此时目录在册,合法放行)
194
+ // 目录不可得注入:llm 服务摘走 listProviders(抛错)——包装 installFaults 之后的 ctx.get('llm')
195
+ var withFaultGet = env.ctx.get
196
+ env.ctx.get = function (name) {
197
+ if (name === 'llm') {
198
+ return {
199
+ listProviders: function () { throw new Error('llm 服务已摘除 listProviders(目录不可得注入)') },
200
+ listModels: async function () { return [] },
201
+ }
202
+ }
203
+ return withFaultGet(name)
204
+ }
205
+ // 首轮派发:modelDirectory → listProviders 抛错 → catch → null(L167)→ outOfDir 恒 false(L182)
206
+ // → 目录闸整体跳过 → 卡级特例 prov/gone 照常入链 → 派发在 prov/gone 上(不误伤)
207
+ await env.clock.advance(50)
208
+ var run1 = await env.waitFor(function () { return env.runs.length >= 1 ? env.runs[0] : null }, 'spawn 发生')
209
+ assert.equal(h.spawnLog.length, 1, '恰一次 spawn')
210
+ assert.equal(h.spawnLog[0].model, GONE, '不误伤:prov/gone 照常派发(目录闸整体跳过,L158-161 保守语义)')
211
+ assert.deepEqual(run1.req.agentOptions, { provider: 'prov', model: 'gone' }, 'run 真请求带 agentOptions={provider:prov,model:gone}')
212
+ var t = await env.waitFor(function () {
213
+ var x = env.task('task-d3')
214
+ return x && x.status === 'in-progress' && x.claimedBy === run1.id ? x : null
215
+ }, '卡已派发 in-progress 且 claimedBy=run id')
216
+ assert.equal(staleMsgs(t).length, 0, '零 stale-model 留痕(dir=null → traceStaleModel 不触发,不误报)')
217
+ assert.equal(t.runs[0].model, GONE, 'run 落 prov/gone')
218
+ assert.equal(t.runs[0].modelSource, 'card', 'modelSource=card(卡级特例正常入链)')
219
+ // 收尾:正常完成,零死锁零空转
220
+ run1.resolveWith('## 开发描述\n反例用例完成')
221
+ t = await env.waitFor(function () {
222
+ var x = env.task('task-d3')
223
+ return x && x.status === 'resolved' && x.runs && x.runs[0] && x.runs[0].outcome === 'completed' ? x : null
224
+ }, '正常推进到 resolved 且 run 关账 completed(零死锁)')
225
+ } finally { await env.cleanup() }
226
+ })
@@ -0,0 +1,132 @@
1
+ // dsh-agent-board — 故障注入文档回归:路径 B 接口预留(test/fault-injection-docs.test.mjs,task-mvqi3uj4-8)
2
+ // 采用源码级断言(readme-decompose-docs.test.mjs L13-40 读盘断言范式;client 无渲染 harness,
3
+ // 文档类卡片一律读盘断言)。被锁对象:docs/fault-injection.md(docs/ 目录首文件——拆解阶段
4
+ // 「A 起步 + B 留接口」决策的可追溯记录,供后续宿主级立项直接引用):
5
+ // ① 文档存在 + 三节锚点标题在案——A/B 权衡记录 / B 路径接缝契约 / 测试分层纪律;
6
+ // ② A/B 权衡节——A=mock-ctx 故障层(调度逻辑全真、宿主假、轻、五档×六场景)vs
7
+ // B=宿主级 mock provider 插件(全真链路、重、需宿主配合面);首期取 A 理由 + B 触发条件
8
+ // (漏网事故三面:事件面/持久化面/startActivation 真契约)全部落字;
9
+ // ③ B 接缝契约节——宿主侧最小面三项(可编程 subagents provider / llm provider 枚举面 /
10
+ // 会话日志落盘布局)+ 每项的 A 层故障映射(等价注入点)契约词逐字在案;
11
+ // ④ 测试分层纪律节——事故复盘双落纪律(单测断言锁机制 + fault-e2e-* 场景锁接缝)+
12
+ // 新事故类加卡模板指向 fault-e2e-recovery.test.mjs;
13
+ // ⑤ 关键契约词全文在案——startActivation / listProviders / session.v4.jsonl.zstd /
14
+ // 五档故障名(instant-kill/hang/empty/rate-limit/slow)+ 消费点「文件 + 符号」锚;
15
+ // ⑥ 文档与源码同口径——五档故障名逐一在 fault-provider.mjs FAULT_TYPES 常量里(源码真值),
16
+ // 消费点符号在案(dispatch.mjs: hasActivationApi/activationAsRun/activationRequestOf;拆分后
17
+ // model-health.mjs: modelDirectory/probeModel),文档锚不悬空。
18
+ // ⑦ 行号锚清扫锁(task-mvt8c1yx-8 拆分第 8 刀):拆分令行号持续漂移,文档一律按「文件 + 函数/
19
+ // 符号名」定位——旧行号锚(L806-823/L163-178/L294-332)与任何 L<行号> 形式一律不得回潮。
20
+ import { test } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+ import { readFileSync } from 'node:fs'
23
+
24
+ const doc = readFileSync(new URL('../docs/fault-injection.md', import.meta.url), 'utf8')
25
+ const dispatch = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
26
+ const modelHealth = readFileSync(new URL('../lib/model-health.mjs', import.meta.url), 'utf8')
27
+ const faultProvider = readFileSync(new URL('./helpers/fault-provider.mjs', import.meta.url), 'utf8')
28
+
29
+ test('①文档存在 + 三节锚点标题在案(A/B 权衡记录 / B 路径接缝契约 / 测试分层纪律)', () => {
30
+ for (const h of [
31
+ '# 故障注入 · 路径 B 接口预留:宿主级 mock provider 插件接缝契约',
32
+ '## 一、A/B 权衡记录(首期取 A,B 留接口)',
33
+ '## 二、B 路径接缝契约(宿主侧最小面 + A 层故障映射)',
34
+ '## 三、测试分层纪律(事故复盘双落)',
35
+ ]) assert.ok(doc.includes(h), 'fault-injection.md 缺锚点标题:' + h)
36
+ })
37
+
38
+ test('②A/B 权衡节:A 层形态/首期取 A 理由 + B 层形态/触发条件(漏网事故三面)全在案', () => {
39
+ for (const m of [
40
+ 'mock-ctx 故障层', // A 形态
41
+ '宿主级 mock provider 插件', // B 形态
42
+ '调度逻辑全真', // A 全真面
43
+ '宿主假', // A 假面
44
+ '五档故障模式', // 五档
45
+ '六场景', // 首期场景面
46
+ '首期取 A 的理由',
47
+ 'B 的触发条件(何时必须上 B)',
48
+ '漏网事故',
49
+ 'agent/status 事件面事故', // 触发面 (a)
50
+ '会话持久化面事故', // 触发面 (b)
51
+ 'startActivation 真契约偏差', // 触发面 (c)
52
+ '互不替代', // 两套关系口径
53
+ ]) assert.ok(doc.includes(m), 'A/B 权衡节缺锚点:' + m)
54
+ })
55
+
56
+ test('③B 接缝契约节:宿主侧最小面三项 + A 层故障映射(等价注入点)契约词全在案', () => {
57
+ for (const m of [
58
+ '可编程 subagents provider', // 面 1
59
+ 'llm provider 枚举面', // 面 2
60
+ '会话日志落盘布局', // 面 3
61
+ 'startActivation', // 探测/消费点
62
+ "delivery: 'parent'", // continuable 结算语义
63
+ "delivery='caller'", // 一次性语义
64
+ 'childId', // 子会话 id 语义
65
+ 'activationAsRun', // 旧 run 形状适配
66
+ 'activationRequestOf', // 新契约 request 组装
67
+ 'listProviders', // 枚举面
68
+ 'listModels',
69
+ 'llm.stream', // stream 消费点
70
+ 'probeModel',
71
+ 'modelDirectory', // 目录闸数据源
72
+ 'session.v4.jsonl.zstd', // 日志布局
73
+ 'findRunLog',
74
+ 'installFaults', // A 层注入点
75
+ 'synthActivation',
76
+ 'faultLlm',
77
+ 'writeFaultLog',
78
+ 'writeChildLog',
79
+ '等价注入点', // A 层映射口径
80
+ ]) assert.ok(doc.includes(m), 'B 接缝契约节缺契约词:' + m)
81
+ })
82
+
83
+ test('④测试分层纪律节:事故复盘双落纪律 + 新事故类加卡模板指向 fault-e2e-recovery.test.mjs', () => {
84
+ for (const m of [
85
+ '双落纪律',
86
+ '单测断言锁机制',
87
+ 'fault-e2e-* 场景锁接缝',
88
+ 'fault-e2e-circuit.test.mjs', // 现有秒拆熔断面
89
+ 'fault-e2e-hung.test.mjs', // 现有挂死判死面
90
+ 'fault-e2e-recovery.test.mjs', // 新事故类加卡模板指向
91
+ '新事故类加卡模板',
92
+ ]) assert.ok(doc.includes(m), '测试分层纪律节缺锚点:' + m)
93
+ })
94
+
95
+ test('⑤关键契约词全文在案:五档故障名 + startActivation/listProviders/session.v4.jsonl.zstd + 符号锚', () => {
96
+ for (const n of ['instant-kill', 'hang', 'empty', 'rate-limit', 'slow']) {
97
+ assert.ok(doc.includes(n), '文档缺五档故障名:' + n)
98
+ }
99
+ for (const m of ['startActivation', 'listProviders', 'session.v4.jsonl.zstd']) {
100
+ assert.ok(doc.includes(m), '文档缺关键契约词:' + m)
101
+ }
102
+ // 消费点锚一律「文件 + 函数/符号名」口径(task-mvt8c1yx-8):拆分后行号必漂,符号不漂。
103
+ for (const m of ['lib/dispatch.mjs', 'lib/model-health.mjs', 'spawnOneShot', 'hasActivationApi',
104
+ 'activationAsRun', 'activationRequestOf', 'modelDirectory()', 'probeModel()']) {
105
+ assert.ok(doc.includes(m), '文档缺消费点符号锚:' + m)
106
+ }
107
+ for (const dead of ['L806-823', 'L163-178', 'L294-332']) {
108
+ assert.ok(!doc.includes(dead), '文档残留旧行号锚(拆分后已漂移,按符号定位):' + dead)
109
+ }
110
+ assert.ok(!/L\d{2,4}/.test(doc), '文档不得再出现 L<行号> 形式锚点(历次拆分会令其漂移)')
111
+ })
112
+
113
+ test('⑦行号锚清扫:lib/ 与 docs/ 不再有指向 dispatch.mjs 的 L<行号> 锚(预研清单语法说明除外)', () => {
114
+ assert.ok(!/dispatch\.mjs[^\n]*L\d/.test(doc), 'fault-injection.md 残留 dispatch.mjs 行号锚')
115
+ assert.ok(!/dispatch[^\n]*\s\d{3}\b/.test(doc), 'fault-injection.md 残留「dispatch <行号>」式锚')
116
+ })
117
+
118
+ test('⑥文档与源码同口径:五档在 FAULT_TYPES 常量里(源码真值)+ dispatch.mjs 消费点符号在案', () => {
119
+ const types = faultProvider.match(/var FAULT_TYPES = \[([^\]]+)\]/)
120
+ assert.ok(types, 'fault-provider.mjs 缺 FAULT_TYPES 常量')
121
+ for (const n of ['instant-kill', 'hang', 'empty', 'rate-limit', 'slow']) {
122
+ assert.ok(types[1].includes("'" + n + "'"), 'FAULT_TYPES 缺五档故障名:' + n)
123
+ }
124
+ // 文档引用的消费点符号不悬空(符号级同口径):spawn/activation 面仍在 dispatch.mjs,
125
+ // 模型目录/探测面已拆至 lib/model-health.mjs(第 2/4 刀)——文档锚指到新文件,此处回源码复检。
126
+ for (const s of ['hasActivationApi', 'activationAsRun', 'activationRequestOf', 'startActivation']) {
127
+ assert.ok(dispatch.includes(s), 'dispatch.mjs 缺消费点符号:' + s)
128
+ }
129
+ for (const s of ['function modelDirectory', 'function probeModel']) {
130
+ assert.ok(modelHealth.includes(s), 'lib/model-health.mjs 缺消费点符号:' + s)
131
+ }
132
+ })