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,630 @@
1
+ // dsh-agent-board — 机制锁单测:assistant-stream 帧活性双层判定全边界(task-mvrvtamd-3)
2
+ // docs/fault-injection.md §三 双落纪律的机制锁半边:帧活性判定路径(onAssistantFrame 订阅 +
3
+ // hungZeroOutputSweep 帧主路径)的单元级行为断言;fault-e2e-hung.test.mjs 是接缝锁半边(真循环
4
+ // 形态),两套互补。挂死检测 v2(task-mvrvtamd-2 落地,lib/dispatch.mjs):120s 零帧疑似标卡
5
+ // (仅标卡不终止)、600s 零帧判死重派(复用既有 settleRun/HUNG_KILL_PREFIX/熔断连击链);日志
6
+ // 零增长双闸保留为无帧流/不可映射 rec 的兜底,存量语义逐字不变。
7
+ //
8
+ // 必锁场景(卡面七条):①帧持续刷新(含 reasoning-delta chunk)→ 120s/600s 均不判、stuckSince
9
+ // 不出现;②零帧 ≥120s → stuckSince 落卡+ah 留史,不杀不喂熔断、卡保持 in-progress;③疑似后帧
10
+ // 恢复 → stuckSince 清除、零判死;④零帧 ≥600s → outcome=timeout/error + lastError 含「挂死」+
11
+ // 熔断连击 + 自动重派;⑤老宿主形态(ctx.on 缺席/全程零帧)→ 日志双闸兜底逐字照旧;⑥映射边界:
12
+ // 老 start 一次性 run(rec.id≠会话 id)即使全局已有帧观测也走日志闸,不被帧路径误杀;⑦verifier
13
+ // 在跑 run 帧路径覆盖(对齐 settle-stop-interrupt 薄桩挂死⑤的 verifier 语义)。
14
+ //
15
+ // 编排:全部用 createMockCtx + env.emitFrame(test/helpers/mock-ctx.mjs 卡1 已供桩,start/chunk/
16
+ // end 三态重放)+ 真 index.mjs apply(与 integration.test.mjs 同一 harness,不改生产代码)。帧路径
17
+ // 零 fs 日志依赖(logSizeOf 对可映射 rec 跳过);日志闸场景(⑤⑥)靠「日志缺失→logSizeOf=0」
18
+ // (findRunLog 找不到返回 null→0)驱动轮数半边,无需写真 zstd 帧。
19
+ //
20
+ // 时钟纪律:Date.now 真实墙钟不动;虚拟时钟推不动 Date.now——挂死编排按 fault-e2e-hung.test.mjs
21
+ // L79-100 pushBackBaseline 同款先例,patchBoard 把 runs 留档基线字段按真实墙钟倒推,再 tick() 手动
22
+ // 触发 15s 心跳(poolCycle → sweep 轮;tick 不动虚拟时间,软/硬超时臂全程永不触发)。
23
+ //
24
+ // 断言纪律:连击表(modelFailStreak/badModels)/活跃 rec 表在 dispatch 闭包内不可直读——全部走
25
+ // 行为证据:卡面字段(stuckSince/lastError/runs 行/verifyRetries)、history(ah 留史)、
26
+ // sendMessages、spawn 请求 agentOptions 有无(熔断拉黑生效)、spawn 会话数。
27
+ //
28
+ // 等待纪律(task-mvr4lplk 负载 flake 收敛):waitFor 统一 20s 预算(W 常量,fault-e2e-hung
29
+ // .test.mjs L37 同款先例——全量 npm test 并行负载下真实 I/O 收敛显著变慢);仅放宽测试侧等待
30
+ // 预算,不放松挂死判定语义(120s/600s 阈值断言逐字不变)。
31
+ import { test } from 'node:test'
32
+ import assert from 'node:assert/strict'
33
+ import fs from 'node:fs'
34
+ import path from 'node:path'
35
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
36
+ import * as plugin from '../index.mjs'
37
+
38
+ // waitFor 预算(fault-e2e-hung.test.mjs L37 同款):仅放宽测试侧等待预算;判定语义是产品行为
39
+ var W = 20000
40
+
41
+ // ===== 测试工具 =====
42
+
43
+ // 找在跑 run 留档行(id 匹配 + outcome=running;同 id 首派+续跑取最新一条,dispatch sweep 同款倒序)
44
+ function runningRow(env, taskId, runId) {
45
+ return function () {
46
+ var t = env.task(taskId)
47
+ var rows = (t && t.runs) || []
48
+ for (var i = rows.length - 1; i >= 0; i--) {
49
+ var r = rows[i]
50
+ if (r && String(r.id) === String(runId) && r.outcome === 'running') return r
51
+ }
52
+ return null
53
+ }
54
+ }
55
+
56
+ // 建卡封装(fault-e2e-hung.test.mjs L61-68 同形):显式 description 避开空描述软提示
57
+ async function createTask(env, id, extra) {
58
+ var args = { sessionId: env.sid, id: id, title: '帧活性单测 ' + id, description: '帧活性双层判定机制锁用例 ' + id }
59
+ if (extra) { for (var k in extra) args[k] = extra[k] }
60
+ var r = await env.rpc('create-task', args)
61
+ assert.equal(r.ok, true, 'create-task 成功: ' + id)
62
+ assert.equal(r.task.status, 'pending', '非 Team 模式即建即 pending: ' + id)
63
+ return r.task
64
+ }
65
+
66
+ // 把 os.homedir() 钉到本用例临时 HOME(Windows 下认 USERPROFILE;findRunLog 回退 os.homedir() 所必须,
67
+ // 钉死后日志闸读临时空目录 → logSizeOf=0,且不扫真实家目录);返回恢复闭包(finally 里先于 cleanup 调)
68
+ // ——与 fault-e2e-hung.test.mjs L53-58 同款。
69
+ function pinHomedir(env) {
70
+ var had = Object.prototype.hasOwnProperty.call(process.env, 'USERPROFILE')
71
+ var prev = process.env.USERPROFILE
72
+ process.env.USERPROFILE = env.home
73
+ return function () { if (had) process.env.USERPROFILE = prev; else delete process.env.USERPROFILE }
74
+ }
75
+
76
+ // 排水后按真实墙钟倒推 runs 留档基线字段(pushBackBaseline 先例 fault-e2e-hung.test.mjs L79-100 同款
77
+ // 三段式:150ms 保底排水窗 + 内容静默 50ms 收敛 + 3s 上限兜底——防「读旧文档的在飞写」覆盖补丁)。
78
+ // 虚拟时钟推不动 Date.now 墙钟,只能倒推持久基线;field:'frameSilenceSince'(帧主路径)/
79
+ // 'zeroGrowthSince'(日志双闸)。
80
+ // 补丁落定自证(负载 flake 收敛):patchBoard 直写后留 150ms 静默窗再回读板面确认倒推量在位——
81
+ // 写风暴下 50ms 静默窗也可能漏排在飞 stale 写(补丁被冲掉 → 判死编排假超时),被冲掉则重打(最多 3 次)。
82
+ async function pushBackBaseline(env, taskId, runId, field, minusMs) {
83
+ var boardFile = path.join(env.home, '.dsh', 'tasks-' + env.sid + '.json')
84
+ for (var attempt = 1; attempt <= 3; attempt++) {
85
+ await new Promise(function (r) { setTimeout(r, 150) }) // 保底排水窗:烧完上一轮 poolCycle 的在飞写盘
86
+ var t0 = Date.now()
87
+ var lastContent = null
88
+ var seen = false
89
+ var lastChangeAt = t0
90
+ for (;;) {
91
+ var content = null
92
+ try { content = fs.readFileSync(boardFile, 'utf8') } catch (_) {}
93
+ if (!seen || content !== lastContent) { seen = true; lastContent = content; lastChangeAt = Date.now() }
94
+ else if (Date.now() - lastChangeAt >= 50) break // 内容静默:在飞写盘已落定,补丁不会被 stale 回写冲掉
95
+ if (Date.now() - t0 > 3000) break // 上限兜底:极端负载下不无限等待
96
+ await new Promise(function (r) { setTimeout(r, 10) })
97
+ }
98
+ var target = null
99
+ env.patchBoard(function (d) {
100
+ var t = d.tasks.find(function (x) { return x.id === taskId })
101
+ var row = (t.runs || []).find(function (r) { return String(r.id) === String(runId) && r.outcome === 'running' })
102
+ assert.ok(row, '墙钟倒推目标行存在(在跑 run 留档)')
103
+ target = (typeof row[field] === 'number' ? row[field] : Date.now()) - minusMs
104
+ row[field] = target
105
+ })
106
+ // 落定自证:留 150ms 让残余在飞写落定,再回读确认补丁在位;被冲掉则重打
107
+ await new Promise(function (r) { setTimeout(r, 150) })
108
+ var okNow = false
109
+ try {
110
+ var d2 = JSON.parse(fs.readFileSync(boardFile, 'utf8'))
111
+ var t2 = d2.tasks.find(function (x) { return x.id === taskId })
112
+ var row2 = (t2.runs || []).find(function (r) { return String(r.id) === String(runId) && r.outcome === 'running' })
113
+ okNow = !!(row2 && row2[field] === target)
114
+ } catch (_) {}
115
+ if (okNow) return
116
+ }
117
+ throw new Error('墙钟倒推补丁三次未落定(在飞写覆盖): task ' + taskId + ' run ' + runId + ' field ' + field)
118
+ }
119
+
120
+ // 共用引导(机械段,fault-e2e-hung bootHungBoard 同形):临时 HOME + USERPROFILE 钉死 → 真 apply →
121
+ // (可选板级模型注入,熔断归因需要模型;set-board-config 对非在册 id 拒写 → patchBoard 注入,
122
+ // fault-e2e-hung L16-17 同款)→ 建卡 → 自动派发(kickCycle 50ms 去抖)→ 等落卡。返回 { unpin, child }。
123
+ async function bootWorkerBoard(env, taskId, boardPatch) {
124
+ var unpin = pinHomedir(env)
125
+ plugin.apply(env.ctx)
126
+ await createTask(env, taskId, { pipeline: 'full', acceptance: 'npm test' })
127
+ if (boardPatch) env.patchBoard(boardPatch) // 建卡先落盘(板文件存在后才能 patchBoard)
128
+ await env.clock.advance(50) // kickCycle 50ms 去抖 → poolCycle → continuable spawn
129
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'worker spawn 发生', W)
130
+ var child = env.continuables[0]
131
+ await env.waitFor(function () {
132
+ var x = env.task(taskId)
133
+ return x && x.status === 'in-progress' && x.runs && x.runs[0] && x.runs[0].outcome === 'running' ? x : null
134
+ }, '落卡 in-progress 且 runs[0] running', W)
135
+ return { unpin: unpin, child: child }
136
+ }
137
+
138
+ // 首轮帧基线(帧主路径基线写入时序):emitFrame 置 frameStreamSeen+rec 映射+lastFrameAt →
139
+ // 第一轮 tick 消费 dirty(recovery 空:无基线可删、无 stuck 可清)→ 第二轮 tick 零帧 sweep 写基线
140
+ // (frameSilenceSince = lastFrameAt)。返回基线落档后的在跑行。
141
+ async function primeFrameBaseline(env, taskId, childId) {
142
+ env.emitFrame(childId)
143
+ await env.clock.tick()
144
+ await env.clock.tick()
145
+ return env.waitFor(function () {
146
+ var row = runningRow(env, taskId, childId)()
147
+ return row && typeof row.frameSilenceSince === 'number' ? row : null
148
+ }, '零帧 sweep 记帧基线(frameSilenceSince 落 runs 留档)', W)
149
+ }
150
+
151
+ // 帧路径判死一轮:倒推 frameSilenceSince ≥600s(帧主路径无轮数闸,墙钟到线即判)→ 一轮 sweep 判死
152
+ // 关账(settleRun 失败臂)。返回 timeout/error 行。
153
+ async function frameKill(env, taskId, runId) {
154
+ await pushBackBaseline(env, taskId, runId, 'frameSilenceSince', 601000)
155
+ await env.clock.tick()
156
+ return env.waitFor(function () {
157
+ var t = env.task(taskId)
158
+ var rows = (t && t.runs) || []
159
+ for (var i = rows.length - 1; i >= 0; i--) {
160
+ if (rows[i] && String(rows[i].id) === String(runId) && rows[i].outcome === 'timeout/error') return rows[i]
161
+ }
162
+ return null
163
+ }, '600s 帧静默判死关账', W)
164
+ }
165
+
166
+ // ===== 帧①:帧持续刷新(含 reasoning-delta chunk)→ 120s/600s 均不判、stuckSince 不出现 =====
167
+ // start/chunk/end 全算活、chunk 不过滤 type(reasoning-delta 同样刷新 lastFrameAt,dispatch.mjs
168
+ // L1797-1799);帧只进内存(lastFrameAt/frameDirty),sweep 每轮走 recovery 空(无基线可删、无
169
+ // stuck 可清)→ 疑似/判死永不触发。40 轮 = 日志双闸的完整轮数预算(600s/15s),帧路径下零积累。
170
+ test('帧①:帧持续刷新(含 reasoning-delta chunk)40 轮心跳 → 120s/600s 均不判、stuckSince 不出现', async () => {
171
+ var env = createMockCtx()
172
+ var boot = null
173
+ try {
174
+ boot = await bootWorkerBoard(env, 'task-frame-live')
175
+ var child = boot.child
176
+ for (var i = 1; i <= 40; i++) {
177
+ // 偶数轮发 reasoning-delta chunk:chunk 不过滤 type 的边界锁定(任意形状帧同算活)
178
+ env.emitFrame(child.childId, i % 2 === 0
179
+ ? { type: 'reasoning-delta', index: i, time: Date.now() }
180
+ : { type: 'chunk', index: i, time: Date.now() })
181
+ await env.clock.tick()
182
+ }
183
+ // 收尾等一拍:40 轮在飞 sweep 链落定(真实 I/O 不定长),断言拿稳态
184
+ await new Promise(function (r) { setTimeout(r, 500) })
185
+ var t = env.task('task-frame-live')
186
+ assert.equal(t.status, 'in-progress', '帧持续刷新:卡保持 in-progress')
187
+ assert.equal(t.runs[0].outcome, 'running', '零判死(run 仍 running)')
188
+ assert.equal(t.stuckSince, undefined, 'stuckSince 不出现(120s 疑似不触发)')
189
+ assert.ok(!t.lastError, '零 lastError(未判死)')
190
+ assert.equal(env.continuables.length, 1, '零重派(无冷复活/新 spawn)')
191
+ assert.equal(env.sendMessages.length, 0, '零续跑投递(不杀不重派)')
192
+ assert.equal(env.interrupts.length, 0, '零 interrupt(仅观测,不终止)')
193
+ } finally {
194
+ if (boot) boot.unpin()
195
+ await env.cleanup()
196
+ }
197
+ })
198
+
199
+ // ===== 帧②:零帧 ≥120s → stuckSince 落卡 + ah 留史;不杀、不喂熔断连击、卡保持 in-progress =====
200
+ // 边界用留 2s+ 余量:pushBack 倒推量按「100s 不判 / 再推 25s(共 ≥125s)判」编排——负载下真实
201
+ // 墙钟在倒推与 sweep 之间仍会漂移几秒,100s 底留足距 120s 的余量防漂移过线误判。
202
+ test('帧②:零帧 ≥120s → stuckSince 落卡 + ah 留史疑似(仅标卡不终止);不杀、卡保持 in-progress', async () => {
203
+ var env = createMockCtx()
204
+ var boot = null
205
+ try {
206
+ boot = await bootWorkerBoard(env, 'task-frame-suspect')
207
+ var child = boot.child
208
+ // 首轮帧基线:emitFrame → 消费 dirty → 零帧 sweep 写基线(frameSilenceSince = lastFrameAt)
209
+ var row = await primeFrameBaseline(env, 'task-frame-suspect', child.childId)
210
+ assert.equal(row.outcome, 'running', '基线轮不判死')
211
+ // 边界下:100s < 120s → 不落疑似(负断言后 settle 一拍再查)
212
+ await pushBackBaseline(env, 'task-frame-suspect', child.childId, 'frameSilenceSince', 100000)
213
+ await env.clock.tick()
214
+ await new Promise(function (r) { setTimeout(r, 300) }) // sweep 在飞写盘落定
215
+ assert.equal(env.task('task-frame-suspect').stuckSince, undefined, '100s < 120s:不落疑似(HUNG_FRAME_SUSPECT_MS 下边界)')
216
+ // 边界上:再推 25s(共 ≥125s ≥ 120s)→ 疑似落卡
217
+ await pushBackBaseline(env, 'task-frame-suspect', child.childId, 'frameSilenceSince', 25000)
218
+ await env.clock.tick()
219
+ var t = await env.waitFor(function () {
220
+ var x = env.task('task-frame-suspect')
221
+ return x && typeof x.stuckSince === 'number' ? x : null
222
+ }, '≥120s 疑似:stuckSince 落卡', W)
223
+ // ah 留史:疑似标注逐字(actor=rec id、note=🟡 疑似卡顿+120s+仅标卡不终止)
224
+ var suspects = (t.history || []).filter(function (h) { return /疑似卡顿/.test(h.note || '') })
225
+ assert.equal(suspects.length, 1, 'ah 留史恰一条疑似标注')
226
+ assert.match(suspects[0].note, /120s/, '疑似文案带 120s 阈值')
227
+ assert.match(suspects[0].note, /仅标卡,不终止/, '疑似文案带「仅标卡,不终止」语义')
228
+ assert.equal(suspects[0].actor, child.childId, '疑似留史 actor=rec id')
229
+ // 不杀:卡/run 状态零变化、零重派零 interrupt(不喂熔断连击=零判死零 settle)
230
+ assert.equal(t.status, 'in-progress', '疑似不杀:卡保持 in-progress')
231
+ assert.equal(t.runs[0].outcome, 'running', 'run 不关账(仅标卡)')
232
+ assert.ok(!t.lastError, '未判死(零 lastError)')
233
+ assert.equal(env.sendMessages.length, 0, '零重派投递(不喂续跑)')
234
+ assert.equal(env.continuables.length, 1, '零新 spawn(不杀)')
235
+ assert.equal(env.interrupts.length, 0, '零 interrupt(仅标卡不终止)')
236
+ // 幂等:疑似后同态 sweep 不重复标注(!t.stuckSince 守卫)
237
+ await env.clock.tick()
238
+ await new Promise(function (r) { setTimeout(r, 300) })
239
+ assert.equal((env.task('task-frame-suspect').history || []).filter(function (h) { return /疑似卡顿/.test(h.note || '') }).length, 1, '同态 sweep 不重复疑似标注')
240
+ } finally {
241
+ if (boot) boot.unpin()
242
+ await env.cleanup()
243
+ }
244
+ })
245
+
246
+ // ===== 帧③:疑似后帧恢复 → stuckSince 清除、零判死 =====
247
+ // 帧恢复=事件只置内存 dirty,sweep 消费才算:删持久基线(frameSilenceSince)+ 静默未满 600s 同步
248
+ // 清疑似标卡(dispatch.mjs L2238-2246 recovery 分支)。
249
+ test('帧③:疑似后帧恢复 → stuckSince 清除 + ah 留史帧恢复;零判死', async () => {
250
+ var env = createMockCtx()
251
+ var boot = null
252
+ try {
253
+ boot = await bootWorkerBoard(env, 'task-frame-recover')
254
+ var child = boot.child
255
+ // 前置状态:帧基线 → 倒推 125s → 疑似落卡
256
+ await primeFrameBaseline(env, 'task-frame-recover', child.childId)
257
+ await pushBackBaseline(env, 'task-frame-recover', child.childId, 'frameSilenceSince', 125000)
258
+ await env.clock.tick()
259
+ await env.waitFor(function () {
260
+ var x = env.task('task-frame-recover')
261
+ return x && typeof x.stuckSince === 'number' ? x : null
262
+ }, '疑似落卡(前置状态)', W)
263
+ // 帧恢复:emitFrame 置 dirty → 下一轮 sweep 走 recovery:删基线 + 清 stuckSince + ah 留史
264
+ env.emitFrame(child.childId)
265
+ await env.clock.tick()
266
+ var t = await env.waitFor(function () {
267
+ var x = env.task('task-frame-recover')
268
+ return x && x.history && x.history.some(function (h) { return /帧恢复/.test(h.note || '') }) ? x : null
269
+ }, '帧恢复 ah 留史落卡', W)
270
+ assert.equal(t.stuckSince, undefined, 'stuckSince 清除(疑似解除)')
271
+ assert.equal(t.runs[0].frameSilenceSince, undefined, '帧基线清除(recovery 删持久基线)')
272
+ var recov = (t.history || []).filter(function (h) { return /帧恢复/.test(h.note || '') })
273
+ assert.equal(recov.length, 1, 'ah 留史恰一条帧恢复')
274
+ assert.match(recov[0].note, /静默不足 600s/, '帧恢复文案带「静默不足 600s」语义')
275
+ assert.match(recov[0].note, /清除疑似卡顿标记/, '帧恢复文案带清除语义')
276
+ // 零判死:卡/run 状态零变化、零重派
277
+ assert.equal(t.status, 'in-progress', '卡保持 in-progress')
278
+ assert.equal(t.runs[0].outcome, 'running', '零判死(run 仍 running)')
279
+ assert.ok(!t.lastError, '零 lastError')
280
+ assert.equal(env.sendMessages.length, 0, '零重派投递')
281
+ assert.equal(env.continuables.length, 1, '零新 spawn')
282
+ } finally {
283
+ if (boot) boot.unpin()
284
+ await env.cleanup()
285
+ }
286
+ })
287
+
288
+ // ===== 帧④:零帧 ≥600s(patchBoard 倒推 frameSilenceSince)→ 判死 + 熔断连击 + 自动重派 =====
289
+ // 帧路径只换诊断文案(「帧静默 600s」);settleRun 失败臂、HUNG_KILL_PREFIX 前缀(进
290
+ // isModelKillFailure 白名单=喂熔断连击)、重派链全部复用(dispatch.mjs L2256-2258 + L2296-2301)。
291
+ // 熔断连击行为证据:判死后连败软闸未达线(1 次失败 < 2)→ 冷复活续跑走原模型(连击 1 < 3 未拉黑);
292
+ // 连击拉黑半边由帧④b 三连判死行为证据承接。
293
+ test('帧④:零帧 ≥600s → 判死 outcome=timeout/error + lastError 含「挂死」+ 自动重派冷复活', async () => {
294
+ var env = createMockCtx()
295
+ var boot = null
296
+ try {
297
+ boot = await bootWorkerBoard(env, 'task-frame-kill', function (d) { d.workerModel = 'prov/hung' })
298
+ var child = boot.child
299
+ await primeFrameBaseline(env, 'task-frame-kill', child.childId)
300
+ // 倒推 601s ≥ HUNG_FRAME_KILL_MS(600s) → 一轮 sweep 判死(帧路径无轮数闸,墙钟到线即判)
301
+ await frameKill(env, 'task-frame-kill', child.childId)
302
+ var t = env.task('task-frame-kill')
303
+ // 断言①:lastError 含「挂死」+ 帧路径诊断文案 + HUNG_KILL_PREFIX 前缀锚定(喂熔断连击)
304
+ assert.match(String(t.lastError || ''), /挂死/, '挂死原因落卡(lastError)')
305
+ assert.match(String(t.lastError || ''), /帧静默 600s/, '帧路径诊断文案(非日志轮数文案)')
306
+ assert.equal(String(t.lastError || '').indexOf('run 挂死:'), 0, 'HUNG_KILL_PREFIX 前缀锚定(进 isModelKillFailure 白名单=喂熔断连击)')
307
+ // 断言②:outcome=timeout/error + endedAt 落账 + 失败计数/重排留史照旧
308
+ assert.equal(t.runs[0].outcome, 'timeout/error', 'settleRun 失败臂关账')
309
+ assert.ok(t.runs[0].endedAt, '关账写 endedAt')
310
+ assert.equal(t.retryCount, 1, '失败计数照旧(失败臂语义不变)')
311
+ assert.equal(t.stuckSince, undefined, '≥600s 直接判死:不落疑似标记(kill 分支先于 suspect)')
312
+ assert.ok((t.history || []).some(function (h) { return h.from === 'in-progress' && h.to === 'pending' }), '失败臂重排留史(in-progress→pending)')
313
+ // 断言③:自动重派(判死结算→同一轮 pickDispatch→tryResumeWorker)→ 冷复活原子会话
314
+ await env.waitFor(function () {
315
+ var x = env.task('task-frame-kill')
316
+ var r2 = x && x.runs && x.runs.find(function (r) { return r.resume === true })
317
+ return r2 && r2.outcome === 'running' && x.claimedBy === child.childId ? r2 : null
318
+ }, '判死后自动重派(续跑行落卡)', W)
319
+ t = env.task('task-frame-kill')
320
+ var resumeRow = t.runs.find(function (r) { return r.resume === true })
321
+ assert.equal(resumeRow.id, child.childId, '重派目标=原原子会话(同一 childId)')
322
+ assert.equal(resumeRow.model, 'prov/hung', '续跑行 model 归因(原模型经健康闸放行:连击 1 < 3 未拉黑)')
323
+ assert.equal(t.status, 'in-progress', '重派落卡 in-progress')
324
+ assert.equal(t.claimedBy, child.childId, '认领位回原子会话')
325
+ var sm = env.sendMessages.find(function (m) { return m.childId === child.childId })
326
+ assert.ok(sm, '重派=sendMessage 冷复活原子会话')
327
+ assert.match(String((sm.content || [])[0] && sm.content[0].text || ''), /断点续跑/, '冷复活文本=断点续跑指令')
328
+ } finally {
329
+ if (boot) boot.unpin()
330
+ await env.cleanup()
331
+ }
332
+ })
333
+
334
+ // ===== 帧④b:同病模型三连帧判死 → 熔断拉黑(新 spawn 不再选 prov/hung)=====
335
+ // 连击表在 dispatch 闭包内不可直读——行为证据链(fault-e2e-hung E2E② 帧路径翻版):
336
+ // 判死#1(连击1)→ 冷复活续跑(健康闸放行)→ 判死#2(连击2)→ 重派被连败软闸拦截改 fresh spawn
337
+ // → 判死#3(连击3)→ 熔断拉黑(卡 blocked)→ 新卡 spawn 不再带 agentOptions(取模链跳过病模型)。
338
+ test('帧④b:同病模型三连帧判死 → 熔断拉黑——新卡 spawn 不再选 prov/hung(agentOptions 缺席+modelSource=parent)', async () => {
339
+ var env = createMockCtx()
340
+ var boot = null
341
+ try {
342
+ boot = await bootWorkerBoard(env, 'task-frame-c', function (d) { d.workerModel = 'prov/hung' })
343
+ var child1 = boot.child
344
+ // ---- 判死 #1:基线 → 倒推 → 判死(连击 1)→ 冷复活 ----
345
+ await primeFrameBaseline(env, 'task-frame-c', child1.childId)
346
+ await frameKill(env, 'task-frame-c', child1.childId)
347
+ await env.waitFor(function () {
348
+ var x = env.task('task-frame-c')
349
+ var r2 = x && x.runs && x.runs.find(function (r) { return r.resume === true })
350
+ return r2 && r2.outcome === 'running' && x.claimedBy === child1.childId ? r2 : null
351
+ }, '判死#1 后冷复活(resume 行落卡)', W)
352
+ assert.equal(env.sendMessages.length, 1, '判死#1 重派=sendMessage 冷复活恰一次')
353
+ // ---- 判死 #2:续跑行基线(复活后仍零帧)→ 倒推 → 判死(连击 2)→ 连败软闸拦截改 fresh spawn ----
354
+ await env.clock.tick()
355
+ await env.waitFor(function () {
356
+ var row = runningRow(env, 'task-frame-c', child1.childId)()
357
+ return row && typeof row.frameSilenceSince === 'number' ? row : null
358
+ }, '判死#2 基线落档(续跑行)', W)
359
+ await frameKill(env, 'task-frame-c', child1.childId)
360
+ await env.waitFor(function () {
361
+ var x = env.task('task-frame-c')
362
+ return x && x.runs && x.runs.length >= 3 && x.runs[2].outcome === 'running' && !x.runs[2].resume ? x.runs[2] : null
363
+ }, '判死#2 后 fresh spawn 落卡(续跑健康闸拦截改新会话)', W)
364
+ var t2 = env.task('task-frame-c')
365
+ assert.equal(t2.runs[2].model, 'prov/hung', 'fresh spawn 走板级模型(连击 2 < 3 未熔断)')
366
+ assert.equal(t2.runs[2].modelSource, 'board', 'modelSource=board 留档')
367
+ assert.equal(t2.runs[2].continuable, true, 'fresh spawn 是新 continuable 会话')
368
+ assert.ok(t2.messages && t2.messages.some(function (m) { return m.kind === 'resume-fallback' }), '续跑放弃留痕(resume-fallback 消息)')
369
+ var child2 = env.continuables[1]
370
+ assert.ok(child2 && child2.childId !== child1.childId, 'fresh spawn 是新子会话(非冷复活)')
371
+ // ---- 判死 #3:fresh 行基线 → 倒推 → 判死(连击 3 → 熔断拉黑 + 卡 blocked)----
372
+ await env.clock.tick()
373
+ await env.waitFor(function () {
374
+ var row = runningRow(env, 'task-frame-c', child2.childId)()
375
+ return row && typeof row.frameSilenceSince === 'number' ? row : null
376
+ }, '判死#3 基线落档(fresh 行)', W)
377
+ await frameKill(env, 'task-frame-c', child2.childId)
378
+ await env.waitFor(function () {
379
+ var x = env.task('task-frame-c')
380
+ return x && x.status === 'blocked' && x.runs[2] && x.runs[2].outcome === 'timeout/error' ? x : null
381
+ }, '判死#3 判死关账 + 三连败 → 卡 blocked', W)
382
+ assert.match(String(env.task('task-frame-c').lastError || ''), /挂死/, '判死#3 原因落卡')
383
+ // ---- 熔断行为证据:新卡 spawn 不再选 prov/hung(备用链空 → 父模型兜底)----
384
+ await createTask(env, 'task-frame-d', { pipeline: 'full', acceptance: 'npm test' })
385
+ await env.clock.advance(50)
386
+ await env.waitFor(function () { return env.continuables.length >= 3 }, '新卡 spawn 发生(熔断后取模链跳过病模型)', W)
387
+ var child3 = env.continuables[2]
388
+ assert.equal(child3.request && child3.request.agentOptions, undefined, '新 spawn 不再带 prov/hung(agentOptions 缺席=熔断拉黑生效)')
389
+ var t4 = await env.waitFor(function () {
390
+ var x = env.task('task-frame-d')
391
+ return x && x.status === 'in-progress' && x.runs && x.runs[0] && x.runs[0].outcome === 'running' ? x : null
392
+ }, '新卡落卡', W)
393
+ assert.equal(t4.runs[0].model, '', '新 spawn 模型留档为空(父模型兜底)')
394
+ assert.equal(t4.runs[0].modelSource, 'parent', 'modelSource=parent(取模链跳过熔断模型)')
395
+ } finally {
396
+ if (boot) boot.unpin()
397
+ await env.cleanup()
398
+ }
399
+ })
400
+
401
+ // 心跳驱 N 轮零增长(fault-e2e-hung driveZeroGrowthRounds 同款):tick() 手动触发 15s 心跳 →
402
+ // poolCycle → sweep 轮;每轮 waitFor 计数落盘再进下一轮(真 fs I/O 不定长,确保上一轮 sweep
403
+ // 写盘完成后才打下一轮心跳)。
404
+ async function driveZeroGrowth(env, taskId, runId, rounds) {
405
+ for (var i = 1; i <= rounds; i++) {
406
+ await env.clock.tick()
407
+ var target = i
408
+ await env.waitFor(function () {
409
+ var row = runningRow(env, taskId, runId)()
410
+ return row && row.zeroGrowthRounds === target ? row : null
411
+ }, '零增长第 ' + target + ' 轮计数落盘', W)
412
+ }
413
+ }
414
+
415
+ // ===== 帧⑤:老宿主形态 → 日志双闸兜底逐字照旧 =====
416
+ // 帧路径启用条件 = frameStreamSeen && frameSessionIdOf(rec)(dispatch.mjs L2230)——两形态都让
417
+ // 前半不成立:⑤a ctx.on 缺席(订阅静默跳过,frameStreamSeen 恒 false);⑤b ctx.on 在位但全程零帧。
418
+ // 日志双闸存量语义逐字断言:首见只记基线(logSizeOf=0)→ 40 轮 × 600s 墙钟双闸 → 判死文案
419
+ // 「连续 40 轮会话日志零增长」,零帧路径字段(frameSilenceSince/stuckSince 只属帧路径)。
420
+ test('帧⑤a:老宿主形态(ctx.on 缺席)→ 帧订阅静默跳过,日志双闸兜底逐字照旧(40 轮×600s 判死)', async () => {
421
+ var env = createMockCtx()
422
+ var unpin = pinHomedir(env)
423
+ try {
424
+ delete env.ctx.on // 老宿主:无事件订阅能力(dispatch.mjs L1806 typeof 检查静默跳过,派发照常)
425
+ plugin.apply(env.ctx)
426
+ await createTask(env, 'task-frame-old', { pipeline: 'full', acceptance: 'npm test' })
427
+ env.patchBoard(function (d) { d.workerModel = 'prov/hung' })
428
+ await env.clock.advance(50)
429
+ await env.waitFor(function () { return env.continuables.length >= 1 }, '老宿主无 ctx.on:派发照常(worker spawn)', W)
430
+ var child = env.continuables[0]
431
+ await env.waitFor(runningRow(env, 'task-frame-old', child.childId), '落卡 in-progress 且 runs[0] running', W)
432
+ // 全程零帧 → frameStreamSeen=false → sweep 走日志双闸:首见只记基线(日志缺失 logSizeOf=0)
433
+ await env.clock.tick()
434
+ var row = await env.waitFor(function () {
435
+ var r = runningRow(env, 'task-frame-old', child.childId)()
436
+ return r && typeof r.lastLogBytes === 'number' && typeof r.zeroGrowthSince === 'number' ? r : null
437
+ }, '日志双闸首见记基线(lastLogBytes/zeroGrowthSince)', W)
438
+ assert.equal(row.lastLogBytes, 0, '日志缺失 logSizeOf=0(不抛错不跳过)')
439
+ assert.equal(row.zeroGrowthRounds, undefined, '首见只记基线不计数(绝不在第一眼判死)')
440
+ assert.equal(row.frameSilenceSince, undefined, '帧路径未启用:零帧基线字段')
441
+ assert.equal(env.task('task-frame-old').stuckSince, undefined, '零 stuckSince(疑似只属帧路径)')
442
+ // 倒推 zeroGrowthSince 601s → 40 轮心跳 → 判死(双闸:40 轮零增长 × 600s 墙钟地板)
443
+ await pushBackBaseline(env, 'task-frame-old', child.childId, 'zeroGrowthSince', 601000)
444
+ await driveZeroGrowth(env, 'task-frame-old', child.childId, 39)
445
+ await env.clock.tick()
446
+ await env.waitFor(function () {
447
+ var x = env.task('task-frame-old')
448
+ return x && x.runs[0] && x.runs[0].outcome === 'timeout/error' ? x : null
449
+ }, '第 40 轮日志双闸判死关账', W)
450
+ var t = env.task('task-frame-old')
451
+ assert.match(String(t.lastError || ''), /挂死/, '挂死原因落卡')
452
+ assert.match(String(t.lastError || ''), /连续 40 轮会话日志零增长/, '日志双闸诊断文案逐字照旧')
453
+ assert.ok(!/帧静默/.test(String(t.lastError || '')), '非帧路径文案(老宿主零帧走日志闸)')
454
+ assert.equal(t.runs[0].zeroGrowthRounds, 40, '轮数半边=40')
455
+ assert.equal(t.runs[0].frameSilenceSince, undefined, '全程零帧路径字段')
456
+ } finally {
457
+ unpin()
458
+ await env.cleanup()
459
+ }
460
+ })
461
+
462
+ // 帧⑤b:ctx.on 在位但全程零帧(frameStreamSeen 恒 false)→ 与 ⑤a 同走日志双闸
463
+ test('帧⑤b:ctx.on 在位但全程零帧 → 帧路径不启用,日志双闸兜底逐字照旧(40 轮×600s 判死)', async () => {
464
+ var env = createMockCtx()
465
+ var unpin = pinHomedir(env)
466
+ try {
467
+ plugin.apply(env.ctx) // ctx.on 在位(订阅建立),但测试全程零帧 → frameStreamSeen 恒 false
468
+ await createTask(env, 'task-frame-oldb', { pipeline: 'full', acceptance: 'npm test' })
469
+ env.patchBoard(function (d) { d.workerModel = 'prov/hung' })
470
+ await env.clock.advance(50)
471
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'worker spawn 发生', W)
472
+ var child = env.continuables[0]
473
+ await env.waitFor(runningRow(env, 'task-frame-oldb', child.childId), '落卡 in-progress 且 runs[0] running', W)
474
+ await env.clock.tick()
475
+ var row = await env.waitFor(function () {
476
+ var r = runningRow(env, 'task-frame-oldb', child.childId)()
477
+ return r && typeof r.lastLogBytes === 'number' && typeof r.zeroGrowthSince === 'number' ? r : null
478
+ }, '日志双闸首见记基线(全程零帧)', W)
479
+ assert.equal(row.lastLogBytes, 0, '日志缺失 logSizeOf=0')
480
+ assert.equal(row.frameSilenceSince, undefined, '零帧观测:帧路径不启用(零帧基线字段)')
481
+ await pushBackBaseline(env, 'task-frame-oldb', child.childId, 'zeroGrowthSince', 601000)
482
+ await driveZeroGrowth(env, 'task-frame-oldb', child.childId, 39)
483
+ await env.clock.tick()
484
+ await env.waitFor(function () {
485
+ var x = env.task('task-frame-oldb')
486
+ return x && x.runs[0] && x.runs[0].outcome === 'timeout/error' ? x : null
487
+ }, '第 40 轮日志双闸判死关账', W)
488
+ var t = env.task('task-frame-oldb')
489
+ assert.match(String(t.lastError || ''), /连续 40 轮会话日志零增长/, '日志双闸诊断文案逐字照旧')
490
+ assert.ok(!/帧静默/.test(String(t.lastError || '')), '非帧路径文案')
491
+ assert.equal(t.runs[0].zeroGrowthRounds, 40, '轮数半边=40')
492
+ assert.equal(env.task('task-frame-oldb').stuckSince, undefined, '零 stuckSince(疑似只属帧路径)')
493
+ } finally {
494
+ unpin()
495
+ await env.cleanup()
496
+ }
497
+ })
498
+
499
+ // ===== 帧⑥:映射边界——老 start 一次性 run(rec.id≠会话 id)即使全局已有帧观测也走日志闸 =====
500
+ // workerContinuable=false → 老 start 路径(mock 无 startActivation → 老 start,run.id=provider run id),
501
+ // rec.frameSessionId 空(dispatch.mjs L990)→ 不可映射帧流;即使全局 frameStreamSeen=true(别的会话
502
+ // 在流式),帧路径对该 rec 关闭 → 日志闸兜底,不被帧路径误杀。
503
+ test('帧⑥:老 start 一次性 run(rec.id≠会话 id)+ 全局已有帧观测 → 仍走日志闸,不被帧路径误杀', async () => {
504
+ var env = createMockCtx()
505
+ var unpin = pinHomedir(env)
506
+ try {
507
+ plugin.apply(env.ctx)
508
+ await createTask(env, 'task-frame-oneshot', { pipeline: 'full', acceptance: 'npm test' })
509
+ env.patchBoard(function (d) { d.workerContinuable = false; d.workerModel = 'prov/hung' }) // 一次性回退开关(老 start 路径)
510
+ await env.clock.advance(50)
511
+ await env.waitFor(function () { return env.runs.length >= 1 }, '一次性 spawn 发生(老 start 路径)', W)
512
+ var run = env.runs[0]
513
+ assert.ok(/^run-/.test(String(run.id)), 'rec.id=provider run id(≠会话 id,frameSessionId 空 = 不可映射帧流)')
514
+ await env.waitFor(runningRow(env, 'task-frame-oneshot', run.id), '落卡 in-progress 且 runs[0] running', W)
515
+ // 全局帧观测(别的会话在流式):frameStreamSeen=true,但本 rec 不可映射 → 帧路径不启用
516
+ env.emitFrame('session-unrelated-streaming')
517
+ await env.clock.tick()
518
+ var row = await env.waitFor(function () {
519
+ var r = runningRow(env, 'task-frame-oneshot', run.id)()
520
+ return r && typeof r.lastLogBytes === 'number' && typeof r.zeroGrowthSince === 'number' ? r : null
521
+ }, '日志闸首见记基线(不可映射 rec)', W)
522
+ assert.equal(row.frameSilenceSince, undefined, '不可映射 rec:零帧基线(帧路径不误启用)')
523
+ assert.equal(env.task('task-frame-oneshot').stuckSince, undefined, '全局帧存在也不落疑似(帧路径对不可映射 rec 关闭)')
524
+ // 日志闸照常判死:倒推 601s + 40 轮(帧路径未误杀:判死只来自日志双闸)
525
+ await pushBackBaseline(env, 'task-frame-oneshot', run.id, 'zeroGrowthSince', 601000)
526
+ await driveZeroGrowth(env, 'task-frame-oneshot', run.id, 39)
527
+ await env.clock.tick()
528
+ await env.waitFor(function () {
529
+ var x = env.task('task-frame-oneshot')
530
+ return x && x.runs[0] && x.runs[0].outcome === 'timeout/error' ? x : null
531
+ }, '第 40 轮日志闸判死关账', W)
532
+ var t = env.task('task-frame-oneshot')
533
+ assert.match(String(t.lastError || ''), /连续 40 轮会话日志零增长/, '日志闸文案(帧路径未接管)')
534
+ assert.ok(!/帧静默/.test(String(t.lastError || '')), '非帧路径文案(不可映射 rec 走日志闸)')
535
+ assert.equal(t.runs[0].zeroGrowthRounds, 40, '日志闸轮数半边')
536
+ assert.equal(t.runs[0].frameSilenceSince, undefined, '全程零帧路径字段')
537
+ // 判死后自动重派:一次性 run 无可复活会话(resumeTarget 天然零命中)→ fresh spawn 新一次性 run
538
+ await env.waitFor(function () {
539
+ var x = env.task('task-frame-oneshot')
540
+ return x && x.runs && x.runs.length >= 2 && x.runs[1].outcome === 'running' ? x : null
541
+ }, '判死后 fresh spawn 重派', W)
542
+ } finally {
543
+ unpin()
544
+ await env.cleanup()
545
+ }
546
+ })
547
+
548
+ // ===== 帧⑦:verifier 在跑 run 帧路径覆盖(对齐薄桩挂死⑤的 verifier 语义)=====
549
+ // verifier 恒走一次性面(dispatch.mjs L923 useContinuable 只对 worker 开)——新宿主契约下经
550
+ // startActivation delivery='caller',activationAsRun 的 run.id=act.childId → rec.frameSessionId
551
+ // 显式记录(L990)→ 帧路径可映射。零帧 ≥600s 判死走 settleVerifier 失败臂(outcome=timeout/error +
552
+ // verifyRetries 重排 + 既有即时熔断语义,settle-stop-interrupt.test.mjs 挂死⑤对齐)。
553
+ test('帧⑦:verifier 在跑 run 帧路径覆盖——零帧 ≥600s 判死走 settleVerifier 失败臂 + 既有即时熔断', async () => {
554
+ var env = createMockCtx()
555
+ var unpin = pinHomedir(env)
556
+ var actSeq = 0
557
+ var activations = []
558
+ try {
559
+ // startActivation 能力桩(新宿主契约):worker=continuable 面(delivery='parent')、
560
+ // verifier=一次性面(delivery='caller')。result 永不落定(真实 activation 只认拆除落定——
561
+ // 挂死形态:verifier 会话在但不产出,零 settle 事件,结算唯一来源=帧静默 sweep)。
562
+ // 挂在 ctx.subagents 上(mock 内部 subagents 与 ctx.subagents 同一对象,spawnOneShot 读它);
563
+ // hasActivationApi 一次性缓存按首次调用现算(apply 内),桩须在 apply 前挂上。
564
+ env.ctx.subagents.startActivation = async function (spec) {
565
+ var act = { childId: 'act-' + (++actSeq), messageId: 'am' + actSeq, result: new Promise(function () {}), dispose: async function () {} }
566
+ activations.push({ childId: act.childId, delivery: spec && spec.delivery, request: spec && spec.request })
567
+ return act
568
+ }
569
+ plugin.apply(env.ctx)
570
+ await createTask(env, 'task-frame-verify', { pipeline: 'full', acceptance: 'npm test' })
571
+ env.patchBoard(function (d) { d.verifierModel = 'prov/vm' })
572
+ await env.clock.advance(50)
573
+ await env.waitFor(function () { return activations.length >= 1 }, 'worker spawn 发生(startActivation continuable 面)', W)
574
+ var child = activations[0].childId
575
+ await env.waitFor(runningRow(env, 'task-frame-verify', child), '落卡 in-progress 且 runs[0] running', W)
576
+ // worker board_report 完成(上报通道,integration.test.mjs 集成a 同款)→ verifying + worker rec 关账
577
+ var rep = await env.tools.get('board_report').execute({ taskId: 'task-frame-verify', kind: 'complete', summary: '实现完成', changes: '', selfTest: 'ok', diffStat: '' })
578
+ assert.equal(rep.ok, true, 'worker 上报成功')
579
+ assert.equal(rep.task.status, 'verifying', '上报完成 → verifying')
580
+ // verifier spawn(startActivation 一次性面 delivery='caller',带 verifierModel agentOptions)
581
+ await env.clock.advance(50)
582
+ var vact = await env.waitFor(function () {
583
+ for (var i = 0; i < activations.length; i++) { if (activations[i].delivery === 'caller') return activations[i] }
584
+ return null
585
+ }, 'verifier spawn 发生(startActivation 一次性面)', W)
586
+ // agentOptions 拆 provider/model 复合格式(dispatch.mjs L911-913:list-models id 是 provider/model)
587
+ assert.deepEqual(vact.request && vact.request.agentOptions, { provider: 'prov', model: 'vm' }, '首派 verifier 带 board 级模型 agentOptions')
588
+ var vrow0 = await env.waitFor(function () {
589
+ var t = env.task('task-frame-verify')
590
+ var rows = (t && t.runs) || []
591
+ for (var i = rows.length - 1; i >= 0; i--) { if (rows[i].role === 'verifier' && rows[i].outcome === 'running') return rows[i] }
592
+ return null
593
+ }, 'verifier run 落卡 running', W)
594
+ // verifier 帧路径:emitFrame(act childId 可映射)→ 消费 dirty → 零帧 sweep 写基线
595
+ env.emitFrame(vact.childId)
596
+ await env.clock.tick()
597
+ await env.clock.tick()
598
+ await env.waitFor(function () {
599
+ var r = runningRow(env, 'task-frame-verify', vact.childId)()
600
+ return r && typeof r.frameSilenceSince === 'number' ? r : null
601
+ }, 'verifier 帧基线落档(帧路径覆盖 verifier run)', W)
602
+ await frameKill(env, 'task-frame-verify', vact.childId)
603
+ // settleVerifier 失败臂:outcome=timeout/error + verifyRetries 重排 + 卡仍 verifying + 即时熔断
604
+ await env.waitFor(function () {
605
+ var x = env.task('task-frame-verify')
606
+ var rows = (x && x.runs) || []
607
+ for (var i = rows.length - 1; i >= 0; i--) { if (rows[i].role === 'verifier' && rows[i].outcome === 'timeout/error') return x }
608
+ return null
609
+ }, 'verifier 判死关账(settleVerifier 失败臂)', W)
610
+ var t = env.task('task-frame-verify')
611
+ assert.match(String(t.lastError || ''), /挂死/, '挂死原因落卡')
612
+ assert.match(String(t.lastError || ''), /帧静默 600s/, '帧路径诊断文案')
613
+ assert.equal(t.verifyRetries, 1, 'verifier 失败臂计数(重新排队审查)')
614
+ assert.equal(t.status, 'verifying', '卡仍 verifying(重新排队)')
615
+ assert.ok((t.history || []).some(function (h) { return /重新排队审查/.test(h.note || '') }), 'ah 留史:重新排队审查')
616
+ // 既有即时熔断语义(薄桩挂死⑤对齐):rec.model=prov/vm 失败即时熔断 → 重排 verifier spawn
617
+ // 跳过病模型(agentOptions 缺席 = 熔断拉黑生效)
618
+ var vact2 = await env.waitFor(function () {
619
+ for (var i = activations.indexOf(vact) + 1; i < activations.length; i++) {
620
+ if (activations[i].delivery === 'caller') return activations[i]
621
+ }
622
+ return null
623
+ }, '重排 verifier spawn 发生(即时熔断后取模链跳过病模型)', W)
624
+ assert.equal(vact2.request && vact2.request.agentOptions, undefined, '重排 spawn 不再带 prov/vm(agentOptions 缺席=即时熔断生效)')
625
+ assert.ok(vact2.childId !== vact.childId, '重排 verifier 是新会话(原挂死 verifier rec 已摘除)')
626
+ } finally {
627
+ unpin()
628
+ await env.cleanup()
629
+ }
630
+ })