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.
- package/README.md +13 -8
- package/index.mjs +1 -0
- package/lib/client/board-list.js +1 -1
- package/lib/client/dashboard.js +52 -7
- package/lib/client/task-detail.js +2 -2
- package/lib/client.js +55 -10
- package/lib/core.mjs +48 -12
- package/lib/dispatch.mjs +135 -1142
- package/lib/hung.mjs +202 -0
- package/lib/model-health.mjs +553 -0
- package/lib/notify.mjs +37 -4
- package/lib/policy.mjs +19 -9
- package/lib/rpc.mjs +7 -3
- package/lib/settle.mjs +954 -0
- package/package.json +1 -1
- package/test/core.test.mjs +339 -77
- package/test/decompose.test.mjs +70 -16
- package/test/fault-e2e-circuit.test.mjs +187 -0
- package/test/fault-e2e-fallback-chain.test.mjs +249 -0
- package/test/fault-e2e-hung.test.mjs +504 -0
- package/test/fault-e2e-recovery.test.mjs +255 -0
- package/test/fault-e2e-softgate.test.mjs +206 -0
- package/test/fault-e2e-stale-dir.test.mjs +226 -0
- package/test/fault-injection-docs.test.mjs +132 -0
- package/test/fault-provider.test.mjs +458 -0
- package/test/helpers/fault-provider.mjs +475 -0
- package/test/helpers/mock-ctx.mjs +9 -0
- package/test/hung-frame-signal.test.mjs +630 -0
- package/test/integration.test.mjs +41 -0
- package/test/lane-gui-bysize.test.mjs +410 -0
- package/test/lane-models.test.mjs +134 -0
- package/test/popover-clamp.test.mjs +92 -0
- package/test/runtime-health.test.mjs +2 -1
- package/test/scorecard-data.test.mjs +2 -1
- package/test/settle-stop-interrupt.test.mjs +294 -3
- package/test/ui-zoom-client.test.mjs +9 -4
package/test/decompose.test.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// ① DECOMPOSE_SCHEMA 常量形状(白名单字段 / required / enum / 上限常量)
|
|
4
4
|
// ② task_decompose 门禁:子代理拒 + 源卡状态门禁(draft/pending 以外拒)+ 在跑幂等拒 + spawn 接线
|
|
5
5
|
// ③ mock 桩全链:startActivation(delivery=caller + outputSchema 透传) → result.structured=split
|
|
6
|
-
// → draft 子卡落位(size/model/touches/acceptance/
|
|
6
|
+
// → draft 子卡落位(size/model/touches/acceptance/dependsOn 序号→真实 id)+ 源卡消息 + 通知
|
|
7
7
|
// ④ verdict=keep(源卡记 rationale、零子卡)与 structured 缺失(失败消息+通知、零子卡)两条路径
|
|
8
8
|
// ⑤ 幂等重拆:旧 draft 提案批清除、已发布旧提案保留、失败不毁旧批、keep 清残留 draft
|
|
9
|
+
// ⑤b dependsOn 序号接线(task-mvr44rm3):'1' 起序号→真实 id+顺序=引用顺序;坏序号丢弃+子卡 messages 留痕;无依赖零影响
|
|
9
10
|
// ⑥ decomposer 不占 activeW(poolCycle:活跃 decomposer rec 下 pending 卡照常派 Worker)
|
|
10
11
|
// + 拆解期源卡不被派 Worker(skip-decomposing 守卫)+ recIsLive 不回收在跑 decomposer
|
|
11
12
|
// ⑦ 物化子卡自带调研包(task-mvnrtpxo):context.files 自动 = touches(拆解官调研结论),
|
|
@@ -29,6 +30,7 @@ function mkBoard(tasks, over) { return Object.assign({ version: 12, ownerSession
|
|
|
29
30
|
function mkDispatch(board, ctxOver, depsOver) {
|
|
30
31
|
var runs = {}
|
|
31
32
|
var notes = []
|
|
33
|
+
var deco = []
|
|
32
34
|
var dispatch = createDispatch(
|
|
33
35
|
Object.assign({ fs: {}, effect: function () {}, get: function () { return null } }, ctxOver || {}),
|
|
34
36
|
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} },
|
|
@@ -41,8 +43,10 @@ function mkDispatch(board, ctxOver, depsOver) {
|
|
|
41
43
|
sessionCwd: function () { return '' }, withTimeout: function (p) { return p }, runsFor: function () { return runs }, feedbackOn: function () { return true },
|
|
42
44
|
pushSysNote: function (sid, text, taskId) { notes.push({ text: text, taskId: taskId || null }) },
|
|
43
45
|
maybeNotify: function () {}, notifyTaskDone: function () {},
|
|
46
|
+
// 拆解官正式回执(task-mvrqiu6x):split/keep 成功走 notifyDecomposed(不再借 pushSysNote 系统异常通道)
|
|
47
|
+
notifyDecomposed: function (sid, payload) { deco.push(payload) },
|
|
44
48
|
}, depsOver || {}))
|
|
45
|
-
return { dispatch: dispatch, runs: runs, notes: notes }
|
|
49
|
+
return { dispatch: dispatch, runs: runs, notes: notes, deco: deco }
|
|
46
50
|
}
|
|
47
51
|
// 新宿主桩(spawn-activation.test.mjs 同形):startActivation 记录 spec,result 直接落定注入值
|
|
48
52
|
function mkDecomposeSubagents(calls, resultValue, opts) {
|
|
@@ -140,7 +144,7 @@ test('① DECOMPOSE_SCHEMA 常量形状:白名单字段 / required / enum /
|
|
|
140
144
|
assert.equal(item.properties.acceptance.type, 'string')
|
|
141
145
|
assert.equal(item.properties.modelHint.type, 'string')
|
|
142
146
|
assert.deepEqual(item.properties.touches, { type: 'array', items: { type: 'string' } })
|
|
143
|
-
assert.deepEqual(item.properties.
|
|
147
|
+
assert.deepEqual(item.properties.dependsOn, { type: 'array', items: { type: 'string' } })
|
|
144
148
|
assert.equal(props.rationale.type, 'string')
|
|
145
149
|
// 白名单字段核验(递归收集 schema 键,不得出现 type/properties/required/items/enum 以外的键)
|
|
146
150
|
var allowed = { type: 1, properties: 1, required: 1, items: 1, enum: 1 }
|
|
@@ -160,7 +164,7 @@ test('① DECOMPOSE_SCHEMA 常量形状:白名单字段 / required / enum /
|
|
|
160
164
|
assert.match(p, /npm test/)
|
|
161
165
|
assert.match(p, /实现侧/)
|
|
162
166
|
assert.match(p, /10~30 分钟/)
|
|
163
|
-
assert.match(p,
|
|
167
|
+
assert.match(p, /有先后顺序时用 dependsOn 引用同批序号/)
|
|
164
168
|
assert.match(p, /没有任何建卡工具/)
|
|
165
169
|
})
|
|
166
170
|
|
|
@@ -261,7 +265,7 @@ test('③ 全链:startActivation(caller + outputSchema) → structured=split
|
|
|
261
265
|
rationale: '两个文件族天然可切',
|
|
262
266
|
children: [
|
|
263
267
|
{ title: '子卡A', description: '做 A', size: 'light', touches: ['lib/a.mjs'], acceptance: 'node --test test/a.test.mjs' },
|
|
264
|
-
{ title: '子卡B', description: '做 B', size: 'heavy', modelHint: 'kimi/kimi-k3', touches: ['lib/b.mjs'], acceptance: 'node --test test/b.test.mjs',
|
|
268
|
+
{ title: '子卡B', description: '做 B', size: 'heavy', modelHint: 'kimi/kimi-k3', touches: ['lib/b.mjs'], acceptance: 'node --test test/b.test.mjs', dependsOn: ['1'] },
|
|
265
269
|
],
|
|
266
270
|
}
|
|
267
271
|
var h = mkDispatch(board, { subagents: mkDecomposeSubagents(calls, { output: [{ type: 'text', text: '提案完毕' }], stopReason: 'completed', structured: proposal }) })
|
|
@@ -296,7 +300,7 @@ test('③ 全链:startActivation(caller + outputSchema) → structured=split
|
|
|
296
300
|
assert.equal(a.context.notes, '', 'notes 留空不重复 description')
|
|
297
301
|
assert.equal(b.context.notes, '')
|
|
298
302
|
assert.equal(b.size, 'heavy'); assert.equal(b.model, 'kimi/kimi-k3', 'modelHint 落 model')
|
|
299
|
-
assert.deepEqual(b.dependsOn, [a.id], '
|
|
303
|
+
assert.deepEqual(b.dependsOn, [a.id], "dependsOn['1'] 序号翻译成本批真实 id")
|
|
300
304
|
assert.equal(b.history[0].actor, 'decomposer')
|
|
301
305
|
assert.ok(b.id.indexOf('epic1-') === 0, '子卡 id 由源卡派生')
|
|
302
306
|
// 源卡:状态不变 + 摘要消息 + run 留档关账
|
|
@@ -305,8 +309,14 @@ test('③ 全链:startActivation(caller + outputSchema) → structured=split
|
|
|
305
309
|
assert.equal(dmsg.length, 1); assert.match(dmsg[0].text, /拆解提案已就位(2 张草稿子卡)/)
|
|
306
310
|
var drec = src.runs.filter(function (r) { return r.role === 'decomposer' })
|
|
307
311
|
assert.equal(drec.length, 1); assert.equal(drec[0].outcome, 'completed')
|
|
308
|
-
//
|
|
309
|
-
|
|
312
|
+
// 通知(task-mvrqiu6x):split 成功走正式回执 notifyDecomposed(「🧩 已拆解」聚合分区),
|
|
313
|
+
// 不再借 pushSysNote 系统通道伪装「⚠️ 系统异常」;源卡 messages 留痕已在上方断言。
|
|
314
|
+
assert.equal(h.notes.length, 0, 'split 成功不再落 pushSysNote 系统异常通道')
|
|
315
|
+
assert.equal(h.deco.length, 1, 'split 成功发拆解回执')
|
|
316
|
+
assert.equal(h.deco[0].sourceId, 'epic1')
|
|
317
|
+
assert.equal(h.deco[0].sourceTitle, '大任务')
|
|
318
|
+
assert.equal(h.deco[0].verdict, 'split')
|
|
319
|
+
assert.equal(h.deco[0].children.length, 2, '回执带本批子卡(标题列表来源)')
|
|
310
320
|
// 活跃表已摘除(结算链收口)
|
|
311
321
|
assert.equal(h.runs['epic1'], undefined)
|
|
312
322
|
})
|
|
@@ -340,7 +350,10 @@ test('④ verdict=keep(记 rationale 零子卡)与 structured 缺失(失
|
|
|
340
350
|
assert.equal(src1.status, 'pending', '源卡状态不变')
|
|
341
351
|
var m1 = src1.messages.filter(function (m) { return m.kind === 'decompose' })
|
|
342
352
|
assert.equal(m1.length, 1); assert.match(m1[0].text, /无需拆分:单点小改不必拆/)
|
|
343
|
-
assert.
|
|
353
|
+
assert.equal(h1.deco.length, 1, 'keep 也走正式回执(同分区「无需拆分」文案)')
|
|
354
|
+
assert.equal(h1.deco[0].verdict, 'keep')
|
|
355
|
+
assert.match(h1.deco[0].reason, /单点小改不必拆/)
|
|
356
|
+
assert.equal(h1.notes.length, 0, 'keep 成功不落 pushSysNote 系统异常通道')
|
|
344
357
|
// structured 缺失:源卡记失败原因 + 通知,零子卡
|
|
345
358
|
var src2 = mkTask({ id: 'k2', status: 'draft' })
|
|
346
359
|
var board2 = mkBoard([src2])
|
|
@@ -351,7 +364,8 @@ test('④ verdict=keep(记 rationale 零子卡)与 structured 缺失(失
|
|
|
351
364
|
assert.equal(board2.tasks.length, 1, 'structured 缺失不产子卡')
|
|
352
365
|
var m2 = src2.messages.filter(function (m) { return m.kind === 'decompose' })
|
|
353
366
|
assert.equal(m2.length, 1); assert.match(m2[0].text, /structured 缺失/)
|
|
354
|
-
assert.ok(h2.notes.some(function (n) { return /未产出可用提案/.test(n.text) }), '
|
|
367
|
+
assert.ok(h2.notes.some(function (n) { return /未产出可用提案/.test(n.text) }), '失败通知仍走 pushSysNote(异常就该是异常)')
|
|
368
|
+
assert.equal(h2.deco.length, 0, 'structured 缺失不产生拆解回执')
|
|
355
369
|
// run 失败臂:stopReason=error → 源卡记失败原因
|
|
356
370
|
var src3 = mkTask({ id: 'k3', status: 'draft' })
|
|
357
371
|
var board3 = mkBoard([src3])
|
|
@@ -371,7 +385,7 @@ test('⑤ 幂等重拆:applyDecomposeProposal 清旧批 draft / 保留已发
|
|
|
371
385
|
var r1 = applyDecomposeProposal(board, src, { verdict: 'split', children: [{ title: '旧A', description: '', size: 'standard', touches: [], acceptance: 'x' }] }, '2026-01-01T01:00:00Z')
|
|
372
386
|
assert.equal(r1.ok, true); assert.equal(r1.children.length, 1)
|
|
373
387
|
// 重拆:新提案落地 → 旧 draft 提案被清(id 是派生量会被新批复用,按标题认旧批),新批落位
|
|
374
|
-
var r2 = applyDecomposeProposal(board, src, { verdict: 'split', children: [{ title: '新A', description: '', size: 'light', touches: [], acceptance: 'y' }, { title: '新B', description: '', size: 'standard', touches: [], acceptance: 'z',
|
|
388
|
+
var r2 = applyDecomposeProposal(board, src, { verdict: 'split', children: [{ title: '新A', description: '', size: 'light', touches: [], acceptance: 'y' }, { title: '新B', description: '', size: 'standard', touches: [], acceptance: 'z', dependsOn: ['1'] }] }, '2026-01-01T02:00:00Z')
|
|
375
389
|
assert.equal(r2.ok, true); assert.equal(r2.children.length, 2)
|
|
376
390
|
assert.ok(!board.tasks.some(function (x) { return x.title === '旧A' }), '旧 draft 提案被清')
|
|
377
391
|
assert.equal(board.tasks.filter(function (x) { return x.parentId === 'r1' }).length, 2)
|
|
@@ -397,17 +411,57 @@ test('⑤ 幂等重拆:applyDecomposeProposal 清旧批 draft / 保留已发
|
|
|
397
411
|
for (var i = 0; i < 9; i++) many.push({ title: '批' + i, description: '', size: 'standard', touches: [], acceptance: 'c' + i })
|
|
398
412
|
var r6 = applyDecomposeProposal(board, src, { verdict: 'split', children: many }, '2026-01-01T06:00:00Z')
|
|
399
413
|
assert.equal(r6.children.length, 8, 'children 上限 8 截断')
|
|
400
|
-
//
|
|
414
|
+
// dependsOn 序号防御(task-mvr44rm3):非法/越界/自引用/后排序号一律丢弃 + 该子卡 messages 留痕(本批无环)
|
|
401
415
|
var board2 = mkBoard([mkTask({ id: 'r2', status: 'draft' })])
|
|
402
416
|
var src2 = board2.tasks[0]
|
|
403
417
|
applyDecomposeProposal(board2, src2, { verdict: 'split', children: [
|
|
404
|
-
{ title: 'A0', description: '', size: 'standard', touches: [], acceptance: 'a',
|
|
405
|
-
{ title: 'B1', description: '', size: 'standard', touches: [], acceptance: 'b',
|
|
418
|
+
{ title: 'A0', description: '', size: 'standard', touches: [], acceptance: 'a', dependsOn: ['0', '1', '2', '9', '-1', 'x'] },
|
|
419
|
+
{ title: 'B1', description: '', size: 'standard', touches: [], acceptance: 'b', dependsOn: ['2', '1', '1'] },
|
|
406
420
|
] }, '2026-01-01T07:00:00Z')
|
|
407
421
|
var a0 = board2.tasks.find(function (x) { return x.title === 'A0' })
|
|
408
422
|
var b1 = board2.tasks.find(function (x) { return x.title === 'B1' })
|
|
409
|
-
assert.deepEqual(a0.dependsOn, [], '
|
|
410
|
-
assert.
|
|
423
|
+
assert.deepEqual(a0.dependsOn, [], '首卡无前排可引:自/越界/负数/非数字序号全丢')
|
|
424
|
+
assert.ok(a0.messages.some(function (m) { return m.kind === 'decompose' && /依赖序号无法解析/.test(m.text) }), '坏序号在该子卡 messages 留痕')
|
|
425
|
+
assert.deepEqual(b1.dependsOn, [a0.id], '只收前排序号(去重)')
|
|
426
|
+
assert.ok(b1.messages.some(function (m) { return m.kind === 'decompose' && /依赖序号无法解析/.test(m.text) }), '自引用序号丢弃+留痕')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// ===== ⑤b dependsOn 序号接线(task-mvr44rm3 定稿):同批序号引用物化为真实任务 id =====
|
|
430
|
+
test('⑤b dependsOn 序号引用:真实 id 映射+顺序=引用顺序+坏序号丢弃留痕+无依赖零影响', function () {
|
|
431
|
+
// ① 有效序号引用:['1','2'] 指本批第 1/2 张提案,映射真实 id,deps 顺序=引用顺序
|
|
432
|
+
var src = mkTask({ id: 'w1', status: 'draft' })
|
|
433
|
+
var board = mkBoard([src])
|
|
434
|
+
var r = applyDecomposeProposal(board, src, { verdict: 'split', children: [
|
|
435
|
+
{ title: '基建', description: '', size: 'standard', touches: [], acceptance: 'a' },
|
|
436
|
+
{ title: '中游', description: '', size: 'standard', touches: [], acceptance: 'b', dependsOn: ['1'] },
|
|
437
|
+
{ title: '下游', description: '', size: 'standard', touches: [], acceptance: 'c', dependsOn: ['2', '1'] },
|
|
438
|
+
] }, '2026-01-01T01:00:00Z')
|
|
439
|
+
assert.equal(r.ok, true)
|
|
440
|
+
var k1 = board.tasks.find(function (x) { return x.title === '基建' })
|
|
441
|
+
var k2 = board.tasks.find(function (x) { return x.title === '中游' })
|
|
442
|
+
var k3 = board.tasks.find(function (x) { return x.title === '下游' })
|
|
443
|
+
assert.deepEqual(k1.dependsOn, [], '无依赖提案零影响')
|
|
444
|
+
assert.deepEqual(k2.dependsOn, [k1.id], "序号 '1' → 本批第 1 张真实 id")
|
|
445
|
+
assert.deepEqual(k3.dependsOn, [k2.id, k1.id], '序号引用映射为真实 id 且顺序=引用顺序')
|
|
446
|
+
assert.equal(k1.messages.length, 0, '有效/无引用不产生留痕消息')
|
|
447
|
+
// ② 坏序号:越界/非数字丢弃 + 该子卡 messages 留一条「依赖序号无法解析」;有效序号照常映射
|
|
448
|
+
var board3 = mkBoard([mkTask({ id: 'w2', status: 'draft' })])
|
|
449
|
+
var src3 = board3.tasks[0]
|
|
450
|
+
var r3 = applyDecomposeProposal(board3, src3, { verdict: 'split', children: [
|
|
451
|
+
{ title: '甲', description: '', size: 'standard', touches: [], acceptance: 'a', dependsOn: ['9', 'abc'] },
|
|
452
|
+
{ title: '乙', description: '', size: 'standard', touches: [], acceptance: 'b', dependsOn: ['1', '5', 'x'] },
|
|
453
|
+
] }, '2026-01-01T02:00:00Z')
|
|
454
|
+
assert.equal(r3.ok, true, '坏序号不阻断物化')
|
|
455
|
+
var jia = board3.tasks.find(function (x) { return x.title === '甲' })
|
|
456
|
+
var yi = board3.tasks.find(function (x) { return x.title === '乙' })
|
|
457
|
+
assert.deepEqual(jia.dependsOn, [], '越界/非数字序号丢弃')
|
|
458
|
+
assert.deepEqual(yi.dependsOn, [jia.id], '混合:有效序号照常映射')
|
|
459
|
+
var tj = jia.messages.filter(function (m) { return m.kind === 'decompose' && /依赖序号无法解析/.test(m.text) })
|
|
460
|
+
assert.equal(tj.length, 1, '子卡 messages 留一条「依赖序号无法解析」')
|
|
461
|
+
assert.match(tj[0].text, /9、abc/, '留痕带坏序号明细')
|
|
462
|
+
var ty = yi.messages.filter(function (m) { return m.kind === 'decompose' && /依赖序号无法解析/.test(m.text) })
|
|
463
|
+
assert.equal(ty.length, 1, '后排溢出+非数字同样留痕')
|
|
464
|
+
assert.match(ty[0].text, /5、x/, '留痕带坏序号明细')
|
|
411
465
|
})
|
|
412
466
|
|
|
413
467
|
// ===== ⑥ decomposer 不占 activeW + 拆解期源卡不派 Worker + recIsLive 不回收 =====
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ===== 故障 E2E:秒拆三连 → 熔断拉黑 + 取模链跳过 + 卡三连败阻塞(真派发循环,task-mvqi3uj4-2)=====
|
|
2
|
+
// 背景:task-mvowb2hc 洞①(宿主秒拆喂熔断)的单测面已由 spawn-activation.test.mjs ⑩⑪⑫ 薄桩锁定
|
|
3
|
+
//(直调 spawnOneShot 手工喂 settle)。本文件补端到端回归:跑在「真实 index.mjs apply(faultCtx)」
|
|
4
|
+
// 全循环上(integration.test.mjs harness 同款:rpc create-task → clock.advance(50) 踢 poolCycle →
|
|
5
|
+
// waitFor 收敛),锁定「秒拆不喂熔断」的派发循环闭环——熔断拉黑后下一轮取模链真跳过病模型,
|
|
6
|
+
// 而非单点连击计数。
|
|
7
|
+
//
|
|
8
|
+
// 故障层两接缝(applyFaults):
|
|
9
|
+
// ① 故障 llm 目录:set-board-config 模型校验经 enumModels() 枚举 llm 清单(rpc.mjs,list-models
|
|
10
|
+
// 同源)——默认 mock-ctx 的 llm 记录器 listProviders 返回 [](空清单),'prov/sick' 不命中 →
|
|
11
|
+
// 拒写「非法模型 id」(rpc.mjs set-board-config 模型校验)。故障层必须先把 prov/sick + prov/alt
|
|
12
|
+
// 放进 llm 目录 set-board-config 才放行——这正是「目录闸」接缝的价值(dispatch 侧 modelDirectory
|
|
13
|
+
// 同源:候选在册 = outOfDir 不拦,病模型才进得了取模链)。
|
|
14
|
+
// ② 故障表 + 新契约 activation:subagents 补 startActivation(hasActivationApi 探测点)→ 派发走
|
|
15
|
+
// v0.2.1 新契约路径;faultTable 标 instant-kill 的模型(按 spec.request.agentOptions.model 命中)
|
|
16
|
+
// spawn 即拆除——activation.result 以拆机原形文本立即拒绝(秒拒)。一次性路径(workerContinuable
|
|
17
|
+
// =false)的 run.result 拒绝经 withTimeout race 臂 finish(null, err) → settleRun → settleWorker
|
|
18
|
+
// 失败臂(errText=String(err),故故障值用拆机原形字符串注入,isModelKillFailure 前缀口径命中)
|
|
19
|
+
// → feedModelFailure(dispatch.mjs L1665 接线)+ settleRunRecord 关账 timeout/error。
|
|
20
|
+
// 连击三连(L249 WORKER_MODEL_CIRCUIT_AFTER=3)→ badModels 拉黑 + pushSysNote 秒拆分态文案(L285)。
|
|
21
|
+
//
|
|
22
|
+
// 断言(四条,对应任务口径):
|
|
23
|
+
// ① 第三次秒拆后新卡(基建卡)spawn 日志的 agentOptions 不再带 prov/sick——badModels 熔断生效,
|
|
24
|
+
// 取模链跳过病模型命中备用链(fallbackModels=['prov/alt']),观测点=故障层记录的 spawn spec
|
|
25
|
+
// (不是单测直读 state);
|
|
26
|
+
// ② 病模型卡 retryCount=3 → blocked(L1666 三连败语义不变);
|
|
27
|
+
// ③ 卡 runs 三条留档 outcome=timeout/error(model 全=prov/sick、modelSource=board,重派全 fresh spawn);
|
|
28
|
+
// ④ 回执冲刷后系统通知含「秒拆」分态文案(非挂死零产出文案,L285 vs L261 分态)。
|
|
29
|
+
//
|
|
30
|
+
// 为什么一次性回退开关(workerContinuable=false):continuable 拆除臂路径的 runs 留档经幽灵回收
|
|
31
|
+
// 关 incomplete(reapGhostRecs 只补收尾),且失败重派会被续跑闸劫持(sendMessage 冷复活已死会话)——
|
|
32
|
+
// 一次性路径 run.result 拒绝经 race 臂关账 timeout/error(与挂死判死/一次性失败同一口径),重派恒
|
|
33
|
+
// fresh spawn(resumeTarget 对非 continuable 恒空),三连循环确定性收敛、零续跑干扰。
|
|
34
|
+
// 注意:connectivity 嗅探关键词(连接/超时/网关/limit 等)不进拆机文案——否则病模型被误登记冷却,
|
|
35
|
+
// 三连循环在第二拆就会被冷却闸截断(熔断与冷却两张表各管各的,本场景只测熔断)。
|
|
36
|
+
import { test } from 'node:test'
|
|
37
|
+
import assert from 'node:assert/strict'
|
|
38
|
+
import { createMockCtx } from './helpers/mock-ctx.mjs'
|
|
39
|
+
import * as plugin from '../index.mjs'
|
|
40
|
+
|
|
41
|
+
var SICK = 'prov/sick'
|
|
42
|
+
var ALT = 'prov/alt'
|
|
43
|
+
// 拆机原形文本(dispatch.mjs activation 拆除臂同款前缀;diagnostic 避开冷却嗅探关键词)
|
|
44
|
+
var TEARDOWN_TEXT = '子会话被宿主拆除(stopReason: error,宿主秒拆注入)'
|
|
45
|
+
|
|
46
|
+
// ===== 故障层:llm 目录接缝 + 故障表 activation 秒拆注入 =====
|
|
47
|
+
function applyFaults(env, faultTable) {
|
|
48
|
+
faultTable = faultTable || {}
|
|
49
|
+
// 接缝①:故障层 llm 目录(prov/sick + prov/alt 在册)——set-board-config 模型校验与 dispatch
|
|
50
|
+
// modelDirectory 同源数据面;不加这层默认 mock llm 空清单 → set-board-config workerModel 拒写
|
|
51
|
+
var origGet = env.ctx.get
|
|
52
|
+
env.ctx.get = function (name) {
|
|
53
|
+
if (name === 'llm') {
|
|
54
|
+
return {
|
|
55
|
+
listProviders: function () { return [{ id: 'prov', name: 'prov' }] },
|
|
56
|
+
listModels: async function (pid) { return pid === 'prov' ? [{ id: 'sick', name: 'sick' }, { id: 'alt', name: 'alt' }] : [] },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return origGet(name)
|
|
60
|
+
}
|
|
61
|
+
// 接缝②:新契约 activation(hasActivationApi 探测点)+ 故障表秒拆。
|
|
62
|
+
// calls = spawn 日志观测点(断言①读 spec.request.agentOptions,不直读 state.badModels)。
|
|
63
|
+
var calls = []
|
|
64
|
+
var n = 0
|
|
65
|
+
env.ctx.subagents.startActivation = async function (spec) {
|
|
66
|
+
calls.push(spec)
|
|
67
|
+
var ao = spec.request && spec.request.agentOptions
|
|
68
|
+
if (ao && ao.model && faultTable['prov/' + ao.model] === 'instant-kill') {
|
|
69
|
+
// 拒绝值=拆机原形文本字符串:settleRun 的 errText=String(err) → isModelKillFailure 前缀命中;
|
|
70
|
+
// 空窗 catch:race 臂挂载前的微任务窗口防 unhandled rejection(拒绝值另有 race 臂消费)
|
|
71
|
+
var result = Promise.reject(TEARDOWN_TEXT)
|
|
72
|
+
result.catch(function () {})
|
|
73
|
+
return { childId: 'child-' + (++n), messageId: 'm' + n, result: result, dispose: async function () {} }
|
|
74
|
+
}
|
|
75
|
+
return { childId: 'child-' + (++n), messageId: 'm' + n, result: new Promise(function () {}), dispose: async function () {} }
|
|
76
|
+
}
|
|
77
|
+
return { calls: calls }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 三键写入(目录闸接缝的实证步骤):workerModel + fallbackModels + workerContinuable
|
|
81
|
+
async function writeBoardConfig(env) {
|
|
82
|
+
var wm = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerModel', value: SICK })
|
|
83
|
+
assert.equal(wm.ok, true, '故障层 llm 目录在册 → set-board-config workerModel=prov/sick 放行')
|
|
84
|
+
var fb = await env.rpc('set-board-config', { sessionId: env.sid, key: 'fallbackModels', value: [ALT] })
|
|
85
|
+
assert.equal(fb.ok, true, 'fallbackModels=[prov/alt] 在册放行')
|
|
86
|
+
var wc = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerContinuable', value: false })
|
|
87
|
+
assert.equal(wc.ok, true, 'workerContinuable=false(一次性回退开关)')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 建卡封装:显式 description 避开空描述软提示
|
|
91
|
+
async function createTask(env, id, extra) {
|
|
92
|
+
var args = { sessionId: env.sid, id: id, title: '故障E2E ' + id, description: '故障回归用例 ' + id }
|
|
93
|
+
if (extra) { for (var k in extra) args[k] = extra[k] }
|
|
94
|
+
var r = await env.rpc('create-task', args)
|
|
95
|
+
assert.equal(r.ok, true, 'create-task 成功: ' + id)
|
|
96
|
+
return r.task
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function transitions(t) { return (t.history || []).map(function (h) { return h.from + '→' + h.to }) }
|
|
100
|
+
|
|
101
|
+
// ===== 主场景:秒拆三连 → 熔断拉黑 + 取模链跳过 + 三连败 blocked(真派发循环)=====
|
|
102
|
+
// 与 spawn-activation.test.mjs ⑩ 的差异:⑩ 是薄桩 mkActivationSubagents 单测(直调 spawnOneShot
|
|
103
|
+
// 手工喂 settle);本用例跑真 poolCycle 重派循环(rpc create-task → clock.advance(50) 踢 poolCycle →
|
|
104
|
+
// settleWorker 失败臂 kickCycle → 下一轮 poolCycle 自动重派),验证「熔断 → 下轮取模跳过」闭环。
|
|
105
|
+
test('故障E2E·秒拆三连:activation.result 秒拒×3 → 熔断拉黑 + 新卡取模链跳过病模型 + 病卡三连败 blocked(真 poolCycle 循环)', async () => {
|
|
106
|
+
var env = createMockCtx()
|
|
107
|
+
try {
|
|
108
|
+
var faults = applyFaults(env, { 'prov/sick': 'instant-kill' })
|
|
109
|
+
plugin.apply(env.ctx)
|
|
110
|
+
await writeBoardConfig(env)
|
|
111
|
+
// 建病模型卡 → 50ms 去抖踢 poolCycle → spawn 于 sick → activation.result 秒拒 →
|
|
112
|
+
// race 臂 finish(null, err) → settleRun → settleWorker 失败臂(L1665 feedModelFailure 连击 1)
|
|
113
|
+
await createTask(env, 'task-s1', { pipeline: 'work' })
|
|
114
|
+
await env.clock.advance(50)
|
|
115
|
+
// 三个 waitFor 一律等「结算链尾」:settleWorker 的状态推进与 settleRunRecord 的 run 关账是两次
|
|
116
|
+
// 持锁写(settleRun L1329 → L1338),只等状态会撞上「pending 已落盘而 run 留档还未关账」的窗口
|
|
117
|
+
//(与集成套件「approved 落 resolved 且 verifier run 关账」同一口径)。
|
|
118
|
+
var t = await env.waitFor(function () {
|
|
119
|
+
var x = env.task('task-s1')
|
|
120
|
+
return x && x.status === 'pending' && x.retryCount === 1 && x.runs && x.runs[0] && x.runs[0].outcome === 'timeout/error' ? x : null
|
|
121
|
+
}, '秒拆#1 结算:activation.result 秒拒 → settleWorker 失败臂回 pending 且 run 关账 timeout/error(等链尾)')
|
|
122
|
+
assert.equal(t.runs.length, 1, '首拆一条 run 留档')
|
|
123
|
+
assert.equal(t.runs[0].outcome, 'timeout/error', '首拆关账 timeout/error(race 臂 → settleRun → settleRunRecord)')
|
|
124
|
+
assert.ok(String(t.lastError).indexOf('被宿主拆除') >= 0, '失败原因落卡(lastError,排查三连败不靠猜)')
|
|
125
|
+
assert.equal(env.continuables.length, 0, '一次性路径零 continuable 子会话')
|
|
126
|
+
assert.equal(env.runs.length, 0, '一次性路径零一次性 start run(走 startActivation 新契约)')
|
|
127
|
+
// 自动重派 ×2 同样秒拆(settleWorker 失败臂 kickCycle → 下一轮 poolCycle → fresh spawn 于 sick;
|
|
128
|
+
// 板级候选只过 badModels 熔断闸不受连败软闸拦,连击 1/2 未达阈值照取板级 sick)
|
|
129
|
+
await env.clock.advance(50)
|
|
130
|
+
t = await env.waitFor(function () { var x = env.task('task-s1'); return x && x.status === 'pending' && x.retryCount === 2 && x.runs && x.runs[1] && x.runs[1].outcome === 'timeout/error' ? x : null }, '秒拆#2 自动重派同样秒拆且关账(等链尾)')
|
|
131
|
+
assert.equal(t.runs.length, 2, '两拆两条留档')
|
|
132
|
+
await env.clock.advance(50)
|
|
133
|
+
t = await env.waitFor(function () { var x = env.task('task-s1'); return x && x.status === 'blocked' && x.retryCount === 3 && x.runs && x.runs[2] && x.runs[2].outcome === 'timeout/error' ? x : null }, '秒拆#3 → 三连败 blocked 且第三条 run 关账(等链尾)')
|
|
134
|
+
// ===== ② 三连败语义逐项 =====
|
|
135
|
+
assert.equal(t.status, 'blocked')
|
|
136
|
+
assert.equal(t.retryCount, 3, '三次秒拆各计一次失败(L249 与熔断同一三击口径)')
|
|
137
|
+
var trs = transitions(t)
|
|
138
|
+
assert.equal(trs[trs.length - 1], 'in-progress→blocked', '第三拆流转方向 in-progress→blocked')
|
|
139
|
+
var lastHist = t.history[t.history.length - 1]
|
|
140
|
+
assert.ok(lastHist.note.indexOf('worker 失败 x3') === 0, '历史注记三连败(待人工介入)')
|
|
141
|
+
// ===== ③ runs 三条留档 outcome=timeout/error(model 全=sick、modelSource=board、重派全 fresh spawn)=====
|
|
142
|
+
assert.equal(t.runs.length, 3, '三次派发各留一条 run')
|
|
143
|
+
assert.ok(t.runs.every(function (r) { return r.outcome === 'timeout/error' && r.endedAt }), '三条全部关账 timeout/error(有 endedAt)')
|
|
144
|
+
assert.ok(t.runs.every(function (r) { return r.model === SICK && r.modelSource === 'board' }), '三次全部取板级 sick(板级候选只过 badModels,连败软闸不拦板级)')
|
|
145
|
+
assert.ok(t.runs.every(function (r) { return !r.resume }), '三连重派全 fresh spawn(零续跑干扰)')
|
|
146
|
+
// spawn 日志:三次全部 agentOptions={provider:'prov',model:'sick'}(故障命中前提成立)
|
|
147
|
+
assert.equal(faults.calls.length, 3, '病模型卡共三次 spawn')
|
|
148
|
+
assert.ok(faults.calls.every(function (c) { return c.request.agentOptions && c.request.agentOptions.model === 'sick' }), '三次 spawn 全部命中 sick(activation.result 秒拒前提)')
|
|
149
|
+
// ===== ① 熔断后新卡(基建卡):badModels 熔断生效,取模链跳过病模型命中备用 =====
|
|
150
|
+
//(settleWorker 失败臂 feedModelFailure 连击 3 → badModels 拉黑;下一张新卡的取模链在 spawnOneShot
|
|
151
|
+
// 消费 badModels → pickHealthyFallback 命中 prov/alt。观测点=spawn 日志的 agentOptions。)
|
|
152
|
+
await createTask(env, 'task-s2', { pipeline: 'work' })
|
|
153
|
+
await env.clock.advance(50)
|
|
154
|
+
var spec = await env.waitFor(function () { return faults.calls.length >= 4 ? faults.calls[3] : null }, '基建卡 spawn 发生(取模链消费熔断名单)')
|
|
155
|
+
assert.ok(spec.request.agentOptions, '基建卡 spawn 带 agentOptions')
|
|
156
|
+
assert.deepEqual(spec.request.agentOptions, { provider: 'prov', model: 'alt' }, '取模链跳过病模型命中备用 prov/alt(观测点=spawn 日志 agentOptions)')
|
|
157
|
+
assert.equal(spec.request.agentOptions.model.indexOf('sick'), -1, 'spawn 日志不再带 prov/sick(badModels 熔断生效)')
|
|
158
|
+
t = await env.waitFor(function () { var x = env.task('task-s2'); return x && x.status === 'in-progress' && x.runs && x.runs[0] && x.runs[0].modelSource === 'fallback' ? x : null }, '基建卡落卡 in-progress 且 modelSource=fallback 留档')
|
|
159
|
+
var fbMsg = (t.messages || []).filter(function (m) { return m.kind === 'fallback' })
|
|
160
|
+
assert.equal(fbMsg.length, 1, '切备用留痕落卡(kind=fallback 消息)')
|
|
161
|
+
assert.ok(fbMsg[0].text.indexOf(SICK) >= 0 && fbMsg[0].text.indexOf(ALT) >= 0, '留痕含原模型与备用模型')
|
|
162
|
+
// ===== ④ 回执冲刷(45s 聚合窗)→ 系统通知含「秒拆」分态(非挂死零产出文案)=====
|
|
163
|
+
// 熔断 pushSysNote 不带 taskId(与验收熔断同口径,不受任务回执冲刷丢弃),攒在 sysNotesBuf
|
|
164
|
+
// 随回执 45s 聚合窗冲刷投递。病模型卡 3 拆派发回执 + 基建卡派发回执共 4 条 < 5 → 45s 窗口触发。
|
|
165
|
+
await env.clock.advance(45000)
|
|
166
|
+
var sent = await env.waitFor(function () { return env.sent.length >= 1 ? env.sent : null }, '45s 聚合窗冲刷投递(系统通知随回执出)')
|
|
167
|
+
var allText = sent.map(function (m) { return (m.content || []).map(function (c) { return c.text || '' }).join('\n') }).join('\n')
|
|
168
|
+
assert.ok(allText.indexOf('宿主秒拆') >= 0, '系统通知含秒拆分态文案(L285 缺省文案「即被宿主秒拆」)')
|
|
169
|
+
assert.ok(allText.indexOf(SICK) >= 0, '通知点名病模型 prov/sick')
|
|
170
|
+
assert.ok(allText.indexOf('挂死零产出') < 0, '非挂死分态文案(L261 HUNG_CIRCUIT_WHY,挂死≠秒拆)')
|
|
171
|
+
} finally { await env.cleanup() }
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ===== 接缝对照:默认 llm 目录(空清单)下 set-board-config workerModel=prov/sick 被目录闸拒写 =====
|
|
175
|
+
// 病模型不在故障层 llm 目录 → enumModels 枚举空清单不命中 → 拒写「非法模型 id」(不落脏值)——
|
|
176
|
+
// 这正是故障层 llm 目录接缝的价值:不放行就永远进不了取模链,秒拆回归无从谈起。
|
|
177
|
+
test('故障E2E·接缝对照:病模型不在 llm 目录 → set-board-config workerModel 被目录闸拒写(不落脏值)', async () => {
|
|
178
|
+
var env = createMockCtx()
|
|
179
|
+
try {
|
|
180
|
+
plugin.apply(env.ctx) // 默认 mock llm:listProviders 返回 [](空清单)
|
|
181
|
+
var r = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerModel', value: SICK })
|
|
182
|
+
assert.equal(r.ok, false, '病模型不在 llm 目录 → 目录闸拒写')
|
|
183
|
+
assert.ok(String(r.error || '').indexOf('非法模型 id') === 0, '报错带「非法模型 id」前缀(带合法清单样例)')
|
|
184
|
+
var board = env.board()
|
|
185
|
+
assert.ok(!board || !board.workerModel, '看板不落脏值(workerModel 未写入)')
|
|
186
|
+
} finally { await env.cleanup() }
|
|
187
|
+
})
|