dsh-agent-board 1.8.0 → 1.8.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.
@@ -530,6 +530,27 @@ test('buildWorkerPrompt: 大文件交付纪律(write 主体+edit 续块)注
530
530
  assert.doesNotMatch(vBody, /大文件交付纪律/)
531
531
  })
532
532
 
533
+ test('buildWorkerPrompt: 前轮半成品提示——runs[] 有已落定 worker run 才出现(task-mvbeallq 残影治理②)', () => {
534
+ // 有前轮已落定 worker run → 提示先 git status 盘点续作(残影被补停/池故障重派后工作区可能有半成品)
535
+ const t1 = mkTask({ id: 'pr1', runs: [{ role: 'worker', id: 'c1', at: '2026-01-01T00:00:00Z', outcome: 'timeout/error', continuable: true }] })
536
+ const p1 = core.buildWorkerPrompt(t1)
537
+ assert.match(p1, /本卡有前轮 run 的痕迹——先 git status 看工作区有无未提交的半成品改动/)
538
+ assert.match(p1, /能续作就在其基础上续作,别盲目从零推倒重来/)
539
+ // 无前轮 run(runs 缺省/空数组)→ 不出现
540
+ assert.doesNotMatch(core.buildWorkerPrompt(mkTask({ id: 'pr2' })), /前轮 run 的痕迹/)
541
+ assert.doesNotMatch(core.buildWorkerPrompt(mkTask({ id: 'pr2b', runs: [] })), /前轮 run 的痕迹/)
542
+ // 只有 verifier run → 不出现(只看 worker 痕迹)
543
+ assert.doesNotMatch(core.buildWorkerPrompt(mkTask({ id: 'pr3', runs: [{ role: 'verifier', id: 'v1', outcome: 'completed' }] })), /前轮 run 的痕迹/)
544
+ // 前轮 worker 仍 running(僵尸/在跑留档)→ 不出现(已落定才算痕迹)
545
+ assert.doesNotMatch(core.buildWorkerPrompt(mkTask({ id: 'pr4', runs: [{ role: 'worker', id: 'c2', outcome: 'running' }] })), /前轮 run 的痕迹/)
546
+ // 各种已落定结局都算痕迹(completed / incomplete / stale-closed / terminated)
547
+ assert.match(core.buildWorkerPrompt(mkTask({ id: 'pr5', runs: [{ role: 'worker', id: 'c3', outcome: 'completed' }] })), /前轮 run 的痕迹/)
548
+ assert.match(core.buildWorkerPrompt(mkTask({ id: 'pr6', runs: [{ role: 'worker', id: 'c4', outcome: 'incomplete' }] })), /前轮 run 的痕迹/)
549
+ assert.match(core.buildWorkerPrompt(mkTask({ id: 'pr7', runs: [{ role: 'worker', id: 'c5', outcome: 'stale-closed' }] })), /前轮 run 的痕迹/)
550
+ // verifier prompt 不含此提示(提示只进 Worker)
551
+ assert.doesNotMatch(core.buildVerifierPrompt(t1), /前轮 run 的痕迹/)
552
+ })
553
+
533
554
  // ===== 预研上下文段(瘦身分离形态,task-muvjs392):笔记全文 + 文件清单,不含文件内容本体 =====
534
555
  // 形态:### 主窗口调研笔记(全文 ≤8000 字符)\n…\n\n### 调研文件清单(- 路径:L起-L止 — 一句用途)
535
556
  test('buildContextPackSection: 空清单返回空串', () => {
@@ -1433,7 +1454,9 @@ test('create-task RPC: 起草 lint 落卡 lintWarnings[],get-tasks 透出(
1433
1454
  const h = mkRpcHandlers(board)
1434
1455
  const r = await h['create-task']({ title: '修复 lint 规则', description: '明确描述', touches: ['src/**'], acceptance: 'node --test' })
1435
1456
  assert.equal(r.ok, true)
1436
- assert.deepEqual(r.task.lintWarnings, [core.TREE_GLOB_LINT])
1457
+ // task-mvbealtf:自动派发形态的建卡返回体 lintWarnings 尾部带派发预警(瞬态)——
1458
+ // 落卡/get-tasks 透出的持久 lintWarnings 不含该行(详情页不残留过期文案)
1459
+ assert.deepEqual(r.task.lintWarnings, [core.TREE_GLOB_LINT, core.DISPATCH_NOW_LINT])
1437
1460
  const got = await h['get-tasks']({})
1438
1461
  assert.deepEqual(got.tasks[0].lintWarnings, [core.TREE_GLOB_LINT])
1439
1462
  })
@@ -1476,6 +1499,101 @@ test('起草 lint 接线:四入口 draftLint 落卡 + 详情 ⚠️ 行 + READ
1476
1499
  assert.equal((rootReadme.match(/lintWarnings\[\]/g) || []).length, 2) // 根 README 同步双处
1477
1500
  })
1478
1501
 
1502
+ // ===== 建卡派发预警(task-mvbealtf / 巡检反馈 n-mvba2neatqoa)=====
1503
+ // 自动派发形态的建卡返回体 lintWarnings 尾部追加 DISPATCH_NOW_LINT(瞬态不落卡);
1504
+ // draft/manual/direct/依赖未满足形态不带。纯函数在 core.mjs,双通道经 rpc.mjs 接线。
1505
+
1506
+ test('willAutoDispatch: 自动派发形态矩阵(pending+auto+非direct+依赖满足才 true)', () => {
1507
+ const depDone = mkTask({ id: 'dep', status: 'resolved' })
1508
+ const depOpen = mkTask({ id: 'dep', status: 'pending' })
1509
+ // 命中:pending + auto + full/work + 无依赖(或依赖已 resolved)
1510
+ assert.equal(core.willAutoDispatch(mkBoard([]), mkTask({})), true)
1511
+ assert.equal(core.willAutoDispatch(mkBoard([]), mkTask({ pipeline: 'work' })), true)
1512
+ assert.equal(core.willAutoDispatch(mkBoard([depDone]), mkTask({ dependsOn: ['dep'] })), true)
1513
+ // 不命中:draft / manual / direct / 依赖未满足
1514
+ assert.equal(core.willAutoDispatch(mkBoard([]), mkTask({ status: 'draft' })), false)
1515
+ assert.equal(core.willAutoDispatch(mkBoard([]), mkTask({ assignMode: 'manual' })), false)
1516
+ assert.equal(core.willAutoDispatch(mkBoard([]), mkTask({ pipeline: 'direct' })), false)
1517
+ assert.equal(core.willAutoDispatch(mkBoard([depOpen]), mkTask({ dependsOn: ['dep'] })), false)
1518
+ // 异常入参零炸
1519
+ assert.equal(core.willAutoDispatch(null, null), false)
1520
+ assert.equal(core.willAutoDispatch(mkBoard([]), null), false)
1521
+ })
1522
+
1523
+ test('appendDispatchWarning: 命中换浅拷贝追加预警(持久本体不动),未命中原样返回', () => {
1524
+ const t = mkTask({})
1525
+ t.lintWarnings = [core.TITLE_NO_VERB_LINT]
1526
+ const out = { ok: true, task: t }
1527
+ const r = core.appendDispatchWarning(out, mkBoard([]), t)
1528
+ assert.deepEqual(r.task.lintWarnings, [core.TITLE_NO_VERB_LINT, core.DISPATCH_NOW_LINT])
1529
+ assert.deepEqual(t.lintWarnings, [core.TITLE_NO_VERB_LINT]) // 持久数组不被污染
1530
+ assert.notEqual(r.task, t) // 响应是浅拷贝,本体不换
1531
+ // 未命中:draft 形态原样返回(同一 task 引用,零字段漂移)
1532
+ const td = mkTask({ status: 'draft' })
1533
+ const outd = { ok: true, task: td }
1534
+ assert.equal(core.appendDispatchWarning(outd, mkBoard([]), td).task, td)
1535
+ // 异常入参原样返回
1536
+ assert.equal(core.appendDispatchWarning(null, mkBoard([]), t), null)
1537
+ assert.equal(core.appendDispatchWarning({ ok: false }, mkBoard([]), t).task, undefined)
1538
+ })
1539
+
1540
+ test('create-task RPC: 自动派发形态返回体带派发预警;draft/direct 形态不带(双形态断言)', async () => {
1541
+ // ① 自动派发形态(缺省 draft=false + auto + 非 direct + 无依赖)→ 返回体带预警,落卡不带
1542
+ let board = mkBoard([])
1543
+ let h = mkRpcHandlers(board)
1544
+ let r = await h['create-task']({ title: '修复预警', description: '明确描述', acceptance: 'node --test' })
1545
+ assert.equal(r.ok, true)
1546
+ assert.ok(r.task.lintWarnings.includes(core.DISPATCH_NOW_LINT))
1547
+ assert.ok(!board.tasks[0].lintWarnings.includes(core.DISPATCH_NOW_LINT)) // 瞬态不落卡
1548
+ // ② draft:true 占位形态 → 不带
1549
+ board = mkBoard([])
1550
+ h = mkRpcHandlers(board)
1551
+ r = await h['create-task']({ title: '修复预警', description: '明确描述', acceptance: 'node --test', draft: true })
1552
+ assert.equal(r.ok, true)
1553
+ assert.ok(!r.task.lintWarnings.includes(core.DISPATCH_NOW_LINT))
1554
+ // ③ direct 管线形态(不进池)→ 不带
1555
+ board = mkBoard([])
1556
+ h = mkRpcHandlers(board)
1557
+ r = await h['create-task']({ title: '解释一下 lint 是什么', description: '说明一下', pipeline: 'direct' })
1558
+ assert.equal(r.ok, true)
1559
+ assert.ok(!(r.task.lintWarnings || []).includes(core.DISPATCH_NOW_LINT))
1560
+ // ④ 依赖未满足形态(不会立即派发)→ 不带
1561
+ board = mkBoard([mkTask({ id: 'dep', title: '修复前置', status: 'pending' })])
1562
+ h = mkRpcHandlers(board)
1563
+ r = await h['create-task']({ title: '修复预警', description: '明确描述', acceptance: 'node --test', dependsOn: ['dep'] })
1564
+ assert.equal(r.ok, true)
1565
+ assert.ok(!r.task.lintWarnings.includes(core.DISPATCH_NOW_LINT))
1566
+ })
1567
+
1568
+ test('task_create 工具: 自动派发形态返回体带派发预警;draft:true 不带(工具通道接线)', async () => {
1569
+ // ① 自动派发形态 → 带
1570
+ let board = mkBoard([])
1571
+ let h = mkRpcHandlers(board)
1572
+ let r = await h.__tools['task_create'].execute({ title: '修复预警', description: '明确描述', acceptance: 'node --test' })
1573
+ assert.equal(r.ok, true)
1574
+ assert.ok(r.task.lintWarnings.includes(core.DISPATCH_NOW_LINT))
1575
+ assert.ok(!board.tasks[0].lintWarnings.includes(core.DISPATCH_NOW_LINT)) // 落卡不含
1576
+ // ② draft:true → 不带
1577
+ board = mkBoard([])
1578
+ h = mkRpcHandlers(board)
1579
+ r = await h.__tools['task_create'].execute({ title: '修复预警', description: '明确描述', acceptance: 'node --test', draft: true })
1580
+ assert.equal(r.ok, true)
1581
+ assert.ok(!r.task.lintWarnings.includes(core.DISPATCH_NOW_LINT))
1582
+ })
1583
+
1584
+ test('建卡派发预警接线:双通道调用 + draft 参数说明加重 + core 三件套(源码级断言)', () => {
1585
+ const host = hostSrc()
1586
+ // 双通道同口径:task_create 工具 + create-task RPC 各一次调用
1587
+ assert.equal((host.match(/appendDispatchWarning\(__out, d, t\)/g) || []).length, 1) // task_create 工具
1588
+ assert.equal((host.match(/appendDispatchWarning\(out, d, t\)/g) || []).length, 1) // create-task RPC
1589
+ // 工具 draft 参数说明加重:点名占位/测试卡请显式 draft:true
1590
+ assert.match(host, /不派发的占位\/测试卡请显式 draft:true/)
1591
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1592
+ assert.match(coreSrc, /export function willAutoDispatch\(d, t\)/)
1593
+ assert.match(coreSrc, /export function appendDispatchWarning\(out, d, t\)/)
1594
+ assert.match(coreSrc, /DISPATCH_NOW_LINT = '此卡将立即进入派发池(约 1s 内被 Worker 领取);想先占位请 draft:true 建草稿'/)
1595
+ })
1596
+
1479
1597
  test('epicPrecheck: 字段有无 + 路径存在性(exists 注入),direct/归档子任务不参与', () => {
1480
1598
  const kids = [
1481
1599
  mkTask({ id: 'k1', title: '无材料', parentId: 'ep', context: { files: [], notes: '' } }),
@@ -3636,7 +3754,7 @@ test('可续跑 Worker⑩(卡2④):interrupt 失败 → 结算不卡死(
3636
3754
  assert.equal(t.claimedBy, 'child-2')
3637
3755
  })
3638
3756
 
3639
- test('可续跑 Worker⑪(卡2⑤):workerContinuable=false → 续跑机制整体短路(零 sendMessage/零 interrupt),一次性 dispose 兜底逐字不变', async () => {
3757
+ test('可续跑 Worker⑪(卡2⑤):workerContinuable=false → 续跑机制整体短路(零 sendMessage/零冷复活),一次性 dispose 兜底不变(task-mv8wuznb 起结算补停 interrupt 一枪)', async () => {
3640
3758
  const parent = { id: FULL_SID }
3641
3759
  const sent = []
3642
3760
  const interrupted = []
@@ -3656,7 +3774,10 @@ test('可续跑 Worker⑪(卡2⑤):workerContinuable=false → 续跑机
3656
3774
  await h.dispatch.poolCycle(FULL_SID)
3657
3775
  for (let i = 0; i < 4; i++) await flush()
3658
3776
  assert.deepEqual(sent, []) // 零冷复活
3659
- assert.deepEqual(interrupted, []) // 零 interrupt(一次性路径只 dispose)
3777
+ // task-mv8wuznb 语义修订:一次性 run 结算落定后主动 interrupt 子会话(「结算即销毁」的补停,
3778
+ // 堵「结算≠停止」空转洞——实证 verifier 落账后空转 8h)。这枪是销毁收尾,与续跑机制的
3779
+ // 「interrupt 留存」(endContinuable,打断 turn 留会话)语义相反、互不干扰。
3780
+ assert.deepEqual(interrupted, ['run-1']) // 一次性结算补停恰一枪(dispose 之外的增量)
3660
3781
  assert.ok(disposed >= 1) // 旧兜底照旧(run.dispose)
3661
3782
  assert.equal(t.status, 'pending') // 失败结算语义不变
3662
3783
  assert.equal(t.retryCount, 1)
@@ -3812,9 +3933,11 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
3812
3933
  // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)、
3813
3934
  // 自测指南归一(normalizeUserTest,task-muxyyvg0)扩展;
3814
3935
  // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)、
3936
+ // depsSatisfied(task-mv9yupm4,spawn 落闸锁内补验依赖)、
3815
3937
  // Verifier 验收员加餐常量(VERIFIER_PERSONA/VERIFIER_TOOL_FILTER,task-muy3gm03)
3816
- assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER \} = core/)
3817
- assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint \} = core/)
3938
+ assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER \} = core/)
3939
+ // rpc.mjs 解构表尾部再随建卡派发预警(appendDispatchWarning,task-mvbealtf)扩展
3940
+ assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint, appendDispatchWarning \} = core/)
3818
3941
  // 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;
3819
3942
  // 但其内部已委托共享 helper maybeAutoCloseParent——单一判定口径,core.mjs 不在 hostSrc 清单,单独读)
3820
3943
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
@@ -6119,6 +6242,98 @@ test('set-board-config 模型校验④b:provider 枚举失败不阻塞,其
6119
6242
  assert.equal(board.workerModel, 'kimi/kimi-k2')
6120
6243
  })
6121
6244
 
6245
+ // ===== 板级配置审计行 + 模型下拉两段式(task-mv9yuq2l,实证 n-mv8ki92qnhdq:verifierModel 神隐回退零留痕无从审计)=====
6246
+ // ① set-board-config 成功写入落 {key, from, to, actor, at};拒写/非白名单不落 ② 截尾 50 条 + get-tasks 透出
6247
+ // ③ seed/normalizeBoard 归一兜底 ④ 下拉两段式源码级:选中不调 rpc、点保存才调
6248
+ test('配置审计①:set-board-config 成功写入落审计行 {key, from, to, actor, at};拒写/非白名单不落', async () => {
6249
+ const board = mkBoard([])
6250
+ const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
6251
+ await h['set-board-config']({ key: 'maxWorkers', value: 5 })
6252
+ assert.equal(board.configLog.length, 1)
6253
+ const e1 = board.configLog[0]
6254
+ assert.equal(e1.key, 'maxWorkers')
6255
+ assert.equal(e1.from, null) // 老看板首写(无该字段)→ null
6256
+ assert.equal(e1.to, 5)
6257
+ assert.equal(e1.actor, 'tester') // rpcActor → getActorId()
6258
+ assert.ok(typeof e1.at === 'string' && e1.at.length > 0) // ISO 时间戳在位
6259
+ // 二次写入:from 读到上轮落盘值(审计链可追「从什么改成什么」)
6260
+ await h['set-board-config']({ key: 'maxWorkers', value: 8 })
6261
+ assert.equal(board.configLog.length, 2)
6262
+ assert.equal(board.configLog[1].from, 5)
6263
+ assert.equal(board.configLog[1].to, 8)
6264
+ // to 记钳制后的实际落盘真值(入参 999 → 钳到 10),审计关心真值不是入参
6265
+ await h['set-board-config']({ key: 'maxWorkers', value: 999 })
6266
+ assert.equal(board.configLog[2].to, 10)
6267
+ // 模型键(带枚举校验通道)成功也落:from/to 是 trim 后真值
6268
+ await h['set-board-config']({ key: 'verifierModel', value: ' kimi/kimi-k2 ' })
6269
+ const em = board.configLog[3]
6270
+ assert.equal(em.key, 'verifierModel'); assert.equal(em.from, null); assert.equal(em.to, 'kimi/kimi-k2')
6271
+ // 校验拒写(非法模型 id)不落审计行
6272
+ const rBad = await h['set-board-config']({ key: 'verifierModel', value: 'kimi/kimi-k3' })
6273
+ assert.equal(rBad.ok, false)
6274
+ assert.equal(board.configLog.length, 4)
6275
+ // 非白名单键:老契约照返 ok:true,但无实际写入 → 不落审计行
6276
+ const rX = await h['set-board-config']({ key: 'nonsenseKey', value: 1 })
6277
+ assert.equal(rX.ok, true)
6278
+ assert.equal(board.configLog.length, 4)
6279
+ })
6280
+
6281
+ test('配置审计②:截尾留最新 50 条;get-tasks 透出(老看板缺字段=空数组)', async () => {
6282
+ const board = mkBoard([])
6283
+ const h = mkRpcHandlers(board)
6284
+ for (let i = 0; i < 55; i++) await h['set-board-config']({ key: 'maxWorkers', value: (i % 10) + 1 })
6285
+ assert.equal(board.configLog.length, 50) // 截尾:容量恒定 50
6286
+ assert.equal(board.configLog[49].to, (54 % 10) + 1) // 最新一条 = 第 55 次写入
6287
+ assert.equal(board.configLog[0].to, (5 % 10) + 1) // 最旧一条 = 第 6 次写入(前 5 条已被裁掉)
6288
+ const got = await h['get-tasks']({})
6289
+ assert.ok(Array.isArray(got.configLog))
6290
+ assert.equal(got.configLog.length, 50)
6291
+ assert.equal(got.configLog[49].key, 'maxWorkers')
6292
+ // 老看板(磁盘文件无 configLog 字段):透出空数组,不炸
6293
+ const h2 = mkRpcHandlers(mkBoard([]))
6294
+ assert.deepEqual((await h2['get-tasks']({})).configLog, [])
6295
+ })
6296
+
6297
+ test('配置审计③:seed 初值自带 + normalizeBoard 老看板/脏值兜底(core 层归一)', () => {
6298
+ assert.deepEqual(core.seed('s1').configLog, []) // 新板种子自带
6299
+ const d = { tasks: [] }
6300
+ core.normalizeBoard(d)
6301
+ assert.deepEqual(d.configLog, []) // 老看板缺字段 → 补空数组
6302
+ const d2 = { tasks: [], configLog: 'dirty' }
6303
+ core.normalizeBoard(d2)
6304
+ assert.deepEqual(d2.configLog, []) // 脏值收敛
6305
+ const d3 = { tasks: [], configLog: [{ key: 'maxWorkers', from: 3, to: 5, actor: 'a', at: 't' }] }
6306
+ core.normalizeBoard(d3)
6307
+ assert.equal(d3.configLog.length, 1) // 合法存量原样保留
6308
+ })
6309
+
6310
+ test('模型下拉两段式④(源码级):ModelCfg 选中只改草稿态,点 ✓ 保存才调 rpc;组装产物同步', () => {
6311
+ const src = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
6312
+ const start = src.indexOf('function ModelCfg(props)')
6313
+ assert.ok(start >= 0, 'dashboard.js 应定义 ModelCfg')
6314
+ const end = src.indexOf('function ZoomCfg(', start)
6315
+ assert.ok(end > start, 'ModelCfg 之后应紧跟 ZoomCfg(切片边界)')
6316
+ const body = src.slice(start, end)
6317
+ // ① pick 只写本地草稿态(setVal + setDirty),函数体不得出现 rpc(即点即写是事故原形,绝不回退)
6318
+ const pickM = body.match(/function pick\(v\) \{[^}]*\}/)
6319
+ assert.ok(pickM, 'ModelCfg 应定义 pick')
6320
+ assert.equal(pickM[0].indexOf('rpc('), -1, 'pick 不得直接调 rpc(两段式红线)')
6321
+ assert.match(pickM[0], /setVal\(v\)/)
6322
+ assert.match(pickM[0], /setDirty\(v !== \(props\.value \|\| ''\)\)/) // dirty 对比服务端权威值
6323
+ // ② 提交唯一入口 = save()(rpc('set-board-config') 只住在 save 里)
6324
+ const saveM = body.match(/function save\(\) \{[^}]*\}/)
6325
+ assert.ok(saveM, 'ModelCfg 应定义 save')
6326
+ assert.match(saveM[0], /rpc\('set-board-config', \{ key: cfgKey, value: \(val \|\| ''\)\.trim\(\)/)
6327
+ // ③ 下拉分支(if (models) 到降级分支之间):onChange 走 pick;dirty 才渲染保存键(onClick: save)
6328
+ const selBranch = body.slice(body.indexOf('if (models)'), body.indexOf('// 降级'))
6329
+ assert.match(selBranch, /onChange: function \(e\) \{ pick\(e\.target\.value\) \}/)
6330
+ assert.match(selBranch, /dirty \? React\.createElement\('button', \{ onClick: save/)
6331
+ // ④ 组装产物同步:lib/client.js 含两段式 pick,且不再残留「选中即写」的旧 pick
6332
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
6333
+ assert.match(built, /function pick\(v\) \{ setVal\(v\); setDirty\(v !== \(props\.value \|\| ''\)\) \}/)
6334
+ assert.equal(built.indexOf("function pick(v) { setVal(v); setDirty(false); rpc('set-board-config'"), -1, '组装产物不得残留即点即写旧 pick')
6335
+ })
6336
+
6122
6337
  // 熔断通知分态行为测试 harness:一次性 verifier run 失败,捕获 pushSysNote 文案 + badModels 落账
6123
6338
  async function runVerifierFuseCase(errText) {
6124
6339
  const t = mkTask({ id: 'v-fuse', title: '待验收卡', status: 'verifying' })
@@ -2,6 +2,8 @@
2
2
  // 前半(a-d):全链路 / 驳回重派 / dependsOn 接续 / touches 排他
3
3
  // 后半(e-i,task-muxpgq5b):仲裁冻结时钟推进 / 回执聚合过滤 / 歧义去抖 / progress / 孤儿回收
4
4
  // 竞态根修(k-m,task-mv6c7cdm):terminate×续跑在途落闸弃权 / 正常续跑不回归 / fresh spawn 在途弃权
5
+ // 依赖后补根修(n-o,task-mv9yupm4):publish 后补 dependsOn 撞在途 spawn 落闸否决+依赖落定重派 /
6
+ // update-task 在飞卡写依赖 warning 软提示(pending 卡不带)
5
7
  // 九场景全部跑在「真实 index.mjs apply(mockCtx)」上(卡1 harness),不改生产代码;
6
8
  // 每用例独立 mockCtx + 独立临时 HOME,互不污染(HOME env 是进程级,顶层用例默认串行,严禁并行)。
7
9
  //
@@ -641,7 +643,12 @@ test('集成l·terminate竞态②:正常续跑不回归——无 terminate 时
641
643
  var rep = await env.tools.get('board_report').execute({ taskId: 'task-l1', kind: 'complete', summary: '续跑交付', changes: '改了 l', selfTest: 'npm test 全绿', diffStat: '' })
642
644
  assert.equal(rep.ok, true)
643
645
  t = await env.waitFor(function () { var x = env.task('task-l1'); return x && x.status === 'resolved' && x.runs[1].outcome === 'completed' ? x : null }, '续跑轮交付落 resolved 且关账')
644
- assert.equal(env.interrupts.length, interruptBase, '正常续跑路径零新增 interrupt(闸门不误伤)')
646
+ // task-mvbeallq 豁免收紧:completed 落定无续跑位(resumeTarget 只认超时/失败结局)→ settleRunRecord
647
+ // 尾序补停一枪(残影治理①);除此之外续跑全程(闸门/落闸/结算)零新增 interrupt——不误伤仍成立。
648
+ await env.waitFor(function () { return env.interrupts.length >= interruptBase + 1 }, 'completed 落定补停在位')
649
+ assert.equal(env.interrupts.length, interruptBase + 1, '续跑全程仅补停一枪(completed 无续跑位),闸门不误伤')
650
+ assert.equal(env.interrupts[env.interrupts.length - 1].childId, child1, '补停目标=续跑轮子会话(打断 turn 不销毁会话)')
651
+ assert.equal(env.interrupts[env.interrupts.length - 1].authority.kind, 'ancestor', 'authority 形状同款')
645
652
  } finally { await env.cleanup() }
646
653
  })
647
654
 
@@ -694,3 +701,101 @@ test('集成m·terminate竞态③:fresh spawn 在途被 terminate → 落闸
694
701
  t = await env.waitFor(function () { var x = env.task('task-m1'); return x && x.status === 'resolved' && x.runs[0].outcome === 'completed' ? x : null }, 'resolved 收口且 run 关账')
695
702
  } finally { await env.cleanup() }
696
703
  })
704
+
705
+ // ===== 场景 n:dependsOn 后补撞在途 spawn 根修①(task-mv9yupm4,ontology 实证 n-mv8zdii54ajf)=====
706
+ // 竞态时序复刻:B 空依赖 publish → 立即被 claim 占位、spawn 在途挂起 → update-task 后补
707
+ // dependsOn=[F1](写入成功+warning 软提示,但 F1 未 resolved)→ 放行 spawn。修复前:落闸不验依赖,
708
+ // 卡照跑(depsSatisfied 只闸 pickDispatch 挑候选一刻,闸不到在途窗口)。修复后:落闸锁内重验
709
+ // depsSatisfied=false → 弃权(interrupt 新建子会话 + 不落档不注册)+ 占位回退 pending;F1 落定后
710
+ // 下一轮 cycle 正常派发 B,全程单 run 收口。
711
+ test('集成n·dependsOn后补①:publish 后补依赖撞在途 spawn → 落闸否决回 pending,依赖落定后正常派发', async () => {
712
+ var env = createMockCtx()
713
+ try {
714
+ plugin.apply(env.ctx)
715
+ // spawn 挂起只针对 B(按 label 过滤):依赖 F1 的派发不受影响
716
+ var origStartC = env.ctx.subagents.startContinuable
717
+ var spawnHold = null
718
+ var releaseSpawn = null
719
+ env.ctx.subagents.startContinuable = function (o) {
720
+ if (o && o.label === 'worker:task-nb' && spawnHold) return spawnHold.then(function () { return origStartC(o) })
721
+ return origStartC(o)
722
+ }
723
+ // 依赖 F1 先建先派(在跑未 resolved → 后补的依赖处于未满足态)
724
+ await createTask(env, 'task-na', { pipeline: 'work' })
725
+ await env.clock.advance(50)
726
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'F1 spawn 发生')
727
+ var childF1 = env.continuables[0].childId
728
+ await env.waitFor(function () { var x = env.task('task-na'); return x && x.status === 'in-progress' && x.claimedBy === childF1 ? x : null }, 'F1 落卡 in-progress')
729
+ // B 空依赖 publish → 立即被 claim,spawn 在途挂起(在途窗口由测试掌控)
730
+ spawnHold = new Promise(function (res) { releaseSpawn = res })
731
+ await createTask(env, 'task-nb', { pipeline: 'work' })
732
+ await env.clock.advance(50)
733
+ await env.waitFor(function () { var x = env.task('task-nb'); return x && x.status === 'in-progress' && x.claimedBy === 'spawn-pending' ? x : null }, 'B 占位 claim 落卡,spawn 在途')
734
+ // ===== 在途窗口内后补依赖(撞窗):写入成功(不拒写)+ warning 软提示(在飞卡)=====
735
+ var upd = await env.rpc('update-task', { sessionId: env.sid, taskId: 'task-nb', dependsOn: ['task-na'] })
736
+ assert.equal(upd.ok, true, '后补依赖写入成功(软提示不拒写)')
737
+ assert.equal(upd.warning, '卡已在执行中,依赖变更不影响本次 run', '在飞卡写依赖带 warning 字段')
738
+ assert.deepEqual(env.task('task-nb').dependsOn, ['task-na'], '依赖已落卡')
739
+ // ===== 放行 spawn → 落闸锁内重验 depsSatisfied=false → 否决 =====
740
+ spawnHold = null
741
+ releaseSpawn()
742
+ await env.waitFor(function () { return env.continuables.length >= 2 }, 'B 的被否 spawn 实际建过子会话')
743
+ var childB1 = env.continuables[1].childId
744
+ await env.waitFor(function () { return env.interrupts.some(function (x) { return x.childId === childB1 }) }, '被否 spawn 的新建子会话立刻 interrupt(防空跑烧上下文)')
745
+ var tb = await env.waitFor(function () { var x = env.task('task-nb'); return x && x.status === 'pending' && !x.claimedBy ? x : null }, '依赖否决:占位条件化回退 pending')
746
+ assert.ok(!tb.runs || tb.runs.length === 0, '被否派发不落 run 留档(在卡面从未存在过)')
747
+ assert.deepEqual(transitions(tb), ['created→pending', 'pending→in-progress', 'in-progress→pending'], '占位 claim + 否决回退两段历史')
748
+ // 依赖未落定期间不再重派 B(pickDispatch 的 depsSatisfied 门控接管)
749
+ await env.clock.advance(50)
750
+ assert.equal(env.continuables.length, 2, '依赖未满足:B 不再重派')
751
+ assert.equal(env.sendMessages.length, 0, 'B 无留档可续(续跑通道不触发)')
752
+ // ===== 依赖落定 → 下一轮正常派发 B(断言②)=====
753
+ var repF1 = await env.tools.get('board_report').execute({ taskId: 'task-na', kind: 'complete', summary: 'F1 交付', changes: '改了 na', selfTest: 'npm test 全绿', diffStat: '' })
754
+ assert.equal(repF1.ok, true)
755
+ await env.waitFor(function () { var x = env.task('task-na'); return x && x.status === 'resolved' ? x : null }, 'F1 resolved(work 档直落)')
756
+ await env.clock.advance(50)
757
+ await env.waitFor(function () { return env.continuables.length >= 3 }, '依赖落定后 B 正常派发')
758
+ var childB2 = env.continuables[2].childId
759
+ assert.notEqual(childB2, childB1, '重派是新子会话(被弃的已 interrupt 废弃,不留续跑资格)')
760
+ tb = await env.waitFor(function () { var x = env.task('task-nb'); return x && x.status === 'in-progress' && x.claimedBy === childB2 ? x : null }, 'B 重新落卡 in-progress')
761
+ assert.equal(tb.runs.length, 1, 'B 只有一条真实落闸留档(被否决的派发零痕迹)')
762
+ assert.ok(!tb.runs[0].resume, 'fresh spawn 留档(无续跑资格可续)')
763
+ // B 正常完成 → 全链单 run 收口
764
+ var repB = await env.tools.get('board_report').execute({ taskId: 'task-nb', kind: 'complete', summary: 'B 交付', changes: '改了 nb', selfTest: 'npm test 全绿', diffStat: '' })
765
+ assert.equal(repB.ok, true)
766
+ tb = await env.waitFor(function () { var x = env.task('task-nb'); return x && x.status === 'resolved' && x.runs[0].outcome === 'completed' ? x : null }, 'B resolved 收口且 run 关账')
767
+ assert.deepEqual(transitions(tb), ['created→pending', 'pending→in-progress', 'in-progress→pending', 'pending→in-progress', 'in-progress→resolved'], '否决回退后重派全流转序列')
768
+ } finally { await env.cleanup() }
769
+ })
770
+
771
+ // ===== 场景 o:dependsOn 后补根修②(task-mv9yupm4):update-task warning 边界 =====
772
+ // warning 软提示只在「在飞卡」(in-progress 含 spawn-pending 占位 / verifying)写依赖时出现;
773
+ // pending 卡写依赖是正常编辑路径,不带 warning。verifying 与 in-progress 同一条件分支(场景 n
774
+ // 已覆盖 spawn-pending 形态,本场景补在跑 run 形态 + pending 反例 + 工具/RPC 双通道同口径)。
775
+ test('集成o·dependsOn后补②:update-task 在飞卡写依赖带 warning(pending 卡不带,双通道同口径)', async () => {
776
+ var env = createMockCtx()
777
+ try {
778
+ plugin.apply(env.ctx)
779
+ await createTask(env, 'task-oa', { pipeline: 'work' })
780
+ await createTask(env, 'task-ob', { pipeline: 'work', dependsOn: ['task-oa'] })
781
+ await env.clock.advance(50)
782
+ await env.waitFor(function () { var x = env.task('task-oa'); return x && x.status === 'in-progress' ? x : null }, 'oa 落卡 in-progress(真实 run 在跑)')
783
+ assert.equal(env.task('task-ob').status, 'pending', 'ob 被 depsSatisfied 门控在 pending')
784
+ // pending 卡写依赖 → 正常编辑,无 warning 字段
785
+ var r1 = await env.rpc('update-task', { sessionId: env.sid, taskId: 'task-ob', dependsOn: ['task-oa'] })
786
+ assert.equal(r1.ok, true)
787
+ assert.ok(!('warning' in r1), 'pending 卡写依赖不带 warning 字段')
788
+ // in-progress(真实 run 在跑)卡写依赖 → warning(RPC 通道)
789
+ var r2 = await env.rpc('update-task', { sessionId: env.sid, taskId: 'task-oa', dependsOn: [] })
790
+ assert.equal(r2.ok, true)
791
+ assert.equal(r2.warning, '卡已在执行中,依赖变更不影响本次 run', '在飞卡写依赖带 warning(RPC 通道)')
792
+ // task_update 工具通道同口径(两份内联拷贝行为一致)
793
+ var r3 = await env.tools.get('task_update').execute({ taskId: 'task-oa', dependsOn: [] })
794
+ assert.equal(r3.ok, true)
795
+ assert.equal(r3.warning, '卡已在执行中,依赖变更不影响本次 run', '在飞卡写依赖带 warning(工具通道)')
796
+ // 收尾:oa 上报完成(ob 留 pending 不派,cleanup 直接拆环境)
797
+ var rep = await env.tools.get('board_report').execute({ taskId: 'task-oa', kind: 'complete', summary: 'oa 交付', changes: '改了 oa', selfTest: 'npm test 全绿', diffStat: '' })
798
+ assert.equal(rep.ok, true)
799
+ await env.waitFor(function () { var x = env.task('task-oa'); return x && x.status === 'resolved' ? x : null }, 'oa resolved 收口')
800
+ } finally { await env.cleanup() }
801
+ })
@@ -0,0 +1,144 @@
1
+ // dsh-agent-board — 迟到 verdict 三连根修(task-mvf7nve8)
2
+ // 事故实证:Verifier 调 board_verdict 漏带 verdict 字段(undefined 落账),被 lateRejected=!approved
3
+ // 当驳回 → err 级「迟到驳回」误报 + 同一结论多次落账重复投递(J2 task-mvf2lpzk 4 次 / H2 task-mveljjwd 3 次)。
4
+ // 三连逐一锁:① 入参校验(缺/非法 verdict 拒收带指引+零落账)② 通知分态(approved=✅迟到复核 /
5
+ // rejected=🚨迟到驳回,banner 只 rejected 亮)③ 同指纹(taskId+by+verdict)去重零重复通知。
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { createRpc } from '../lib/rpc.mjs'
9
+ import * as core from '../lib/core.mjs'
10
+ import { createNotify } from '../lib/notify.mjs'
11
+ import { computeRuntimeHealthHints } from '../lib/health.mjs'
12
+
13
+ const FULL_SID = 'session-mock-trio-00000000-0001'
14
+
15
+ function mkTask(over) { return Object.assign({ id: 't1', title: 'T', description: '', status: 'pending', priority: 'medium', tags: [], parentId: null, assignMode: 'auto', assignee: null, context: { instructions: '' }, acceptance: '', dependsOn: [], pipeline: 'full', claimedBy: null, claimedAt: null, createdAt: '2026-01-01T00:00:00Z', resolvedAt: null, history: [], messages: [] }, over || {}) }
16
+ function mkBoard(tasks) { return { version: 11, ownerSession: FULL_SID, boardMode: 'auto', verifyUserGuide: true, tasks: tasks || [] } }
17
+
18
+ // RPC/工具直调 harness(与 verify-timing.test.mjs 同款,加 notifyLateApprove 捕获)
19
+ function mkHarness(board, extra) {
20
+ const state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {}, poolHealth: {} }
21
+ const tools = {}
22
+ const calls = { lateRejects: [], lateApproves: [] }
23
+ const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
24
+ const deps = Object.assign({
25
+ getActorId: () => 'verifier-child-1', resolveRoot: (x) => x,
26
+ toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
27
+ rootForSession: () => null, deriveWorkMode: () => 'solo',
28
+ runsFor: () => ({}),
29
+ rt: async () => board, mutateLocked: (sid, fn) => fn(board),
30
+ maybeNotify: () => {}, notifyTaskDone: () => {},
31
+ spawnOneShot: () => {}, accumulateRunUsage: () => {}, readContextPack: async () => null,
32
+ pushSysNote: () => {}, sessionCwd: () => '',
33
+ hasActiveRun: () => false,
34
+ notifyLateReject: (sid, t) => { calls.lateRejects.push(t.id) },
35
+ notifyLateApprove: (sid, t) => { calls.lateApproves.push(t.id) },
36
+ }, extra || {})
37
+ createRpc(ctx, state, deps)
38
+ return { tools: tools, calls: calls, state: state }
39
+ }
40
+
41
+ // ===== ① 入参校验:缺 verdict / 非法值 → 拒收带指引 + 零落账零通知 =====
42
+ test('① board_verdict 缺 verdict / 非法值:拒收带指引,不落账不通知(verifying 卡与已落定卡同口径)', async () => {
43
+ // verifying 卡:缺 verdict
44
+ const b1 = mkBoard([mkTask({ id: 'k1', status: 'verifying' })])
45
+ const h1 = mkHarness(b1)
46
+ const r1 = await h1.tools['board_verdict'].execute({ taskId: 'k1', summary: '全过' }, {})
47
+ assert.equal(r1.ok, false)
48
+ assert.match(r1.error, /verdict 必须是 approved 或 rejected(收到:undefined),请带正确字段重调/)
49
+ assert.equal(b1.tasks[0].status, 'verifying', '状态不动')
50
+ assert.equal(b1.tasks[0].verification, undefined, '零落账:无 verification')
51
+ assert.equal(b1.tasks[0].lateVerdict, undefined, '零落账:无 lateVerdict')
52
+ // 已落定卡:非法值 verdict → 同样拒收(迟到分支也被闸)
53
+ const b2 = mkBoard([mkTask({ id: 'k2', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
54
+ const h2 = mkHarness(b2)
55
+ const r2 = await h2.tools['board_verdict'].execute({ taskId: 'k2', verdict: 'pass', summary: '全过' }, {})
56
+ assert.equal(r2.ok, false)
57
+ assert.match(r2.error, /verdict 必须是 approved 或 rejected(收到:pass)/)
58
+ assert.equal(b2.tasks[0].lateVerdict, undefined, '零落账:非法 verdict 不落 lateVerdict')
59
+ assert.equal(b2.tasks[0].verification, undefined, '零落账:不补建 verification')
60
+ assert.deepEqual(h2.calls.lateRejects, [], '零通知:无迟到驳回')
61
+ assert.deepEqual(h2.calls.lateApproves, [], '零通知:无迟到复核')
62
+ assert.equal(h2.state.poolHealth[FULL_SID], undefined, '零通知:去重指纹也未写')
63
+ // 纯函数闸:lateVerdictApply 非法 verdict 直返 invalid,一字不落
64
+ const t3 = mkTask({ id: 'k3', status: 'resolved' })
65
+ const r3 = core.lateVerdictApply(t3, undefined, 's', 'c', null, 'at', 'by', true)
66
+ assert.equal(r3.invalid, true); assert.match(r3.error, /收到:undefined/)
67
+ assert.equal(t3.lateVerdict, undefined); assert.equal(t3.verification, undefined)
68
+ // 人工通道不受此限:task_verify 正常走(schema 枚举之外的运行时不拦主窗口)
69
+ const b4 = mkBoard([mkTask({ id: 'k4', status: 'verifying' })])
70
+ const h4 = mkHarness(b4)
71
+ const r4 = await h4.tools['task_verify'].execute({ taskId: 'k4', verdict: 'approved' }, {})
72
+ assert.equal(r4.ok, true); assert.equal(b4.tasks[0].status, 'resolved')
73
+ })
74
+
75
+ // ===== ② approved 迟到:notice 级「✅ 迟到复核」通知,非 err 级「🚨 迟到驳回」=====
76
+ test('② approved 迟到:通知走 notifyLateApprove(✅ 迟到复核),不触 notifyLateReject,无 lateRejectNote', async () => {
77
+ const board = mkBoard([mkTask({ id: 'a1', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
78
+ const h = mkHarness(board)
79
+ const r = await h.tools['board_verdict'].execute({ taskId: 'a1', verdict: 'approved', summary: '复跑全过', checks: '## 核对项\n- ok' }, {})
80
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, false)
81
+ assert.equal(board.tasks[0].lateVerdict.verdict, 'approved')
82
+ assert.deepEqual(h.calls.lateApproves, ['a1'], 'approved 迟到 → ✅ 迟到复核通知')
83
+ assert.deepEqual(h.calls.lateRejects, [], 'approved 迟到绝不触 🚨 迟到驳回')
84
+ assert.equal(h.state.poolHealth[FULL_SID].lateRejectNote, undefined, 'health banner 不为 approved 亮')
85
+ // notify 本体文案:✅ 迟到复核 · 维持原判(全过追认),非 err 措辞
86
+ const sent = []
87
+ const notify = createNotify({}, {}, { rt: async () => board, rootForSession: () => ({ followup: (m) => sent.push(m) }), withTimeout: (p) => p, isDispatched: () => false })
88
+ notify.notifyLateApprove(FULL_SID, board.tasks[0])
89
+ assert.equal(sent.length, 1)
90
+ const txt = sent[0].content[0].text
91
+ assert.match(txt, /✅ \[任务看板\] 迟到复核:a1 维持原判(全过追认)/)
92
+ assert.match(txt, /复跑全过/)
93
+ assert.ok(!/🚨|迟到驳回/.test(txt), 'approved 通知无 err 级措辞')
94
+ assert.equal(sent[0].source.summary, '迟到复核: a1')
95
+ })
96
+
97
+ // ===== ③ 同指纹去重:二次落账零重复通知 =====
98
+ test('③ 同指纹(taskId+by+verdict)二次落账:通知只投一次;不同 verdict 指纹不同 → 各投一次', async () => {
99
+ const board = mkBoard([mkTask({ id: 'd1', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
100
+ const h = mkHarness(board)
101
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'approved', summary: '第一次' }, {})
102
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'approved', summary: '第二次(同指纹重投)' }, {})
103
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'approved', summary: '第三次(同指纹重投)' }, {})
104
+ assert.deepEqual(h.calls.lateApproves, ['d1'], '同指纹三次落账只投一次通知')
105
+ assert.equal(h.state.poolHealth[FULL_SID].lastLateNotice, 'd1|verifier-child-1|approved', '指纹 = taskId+by+verdict')
106
+ // 指纹变(verdict 翻驳回)→ 新通知照投(reject 通道)
107
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'rejected', summary: '翻驳回' }, {})
108
+ assert.deepEqual(h.calls.lateRejects, ['d1'], '指纹不同 → rejected 通知照投')
109
+ // 驳回同指纹重投 → 也去重
110
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'rejected', summary: '驳回重投' }, {})
111
+ assert.deepEqual(h.calls.lateRejects, ['d1'], 'rejected 同指纹重投零重复')
112
+ })
113
+
114
+ // ===== ④ rejected 迟到不回归:维持 err「🚨 迟到驳回」+ lateRejectNote =====
115
+ test('④ rejected 迟到(卡已 approved):维持 err 级「🚨 迟到驳回」通知 + healthHint 不回归', async () => {
116
+ const board = mkBoard([mkTask({ id: 'r1', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
117
+ const h = mkHarness(board)
118
+ const r = await h.tools['board_verdict'].execute({ taskId: 'r1', verdict: 'rejected', summary: '单测红', checks: '## 核对项\n- 红' }, {})
119
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, true)
120
+ assert.deepEqual(h.calls.lateRejects, ['r1'])
121
+ assert.deepEqual(h.calls.lateApproves, [], 'rejected 绝不触 ✅ 迟到复核')
122
+ assert.equal(h.state.poolHealth[FULL_SID].lateRejectNote.taskId, 'r1', 'err banner 数据源照写')
123
+ // notify 本体文案:🚨 迟到驳回
124
+ const sent = []
125
+ const notify = createNotify({}, {}, { rt: async () => board, rootForSession: () => ({ followup: (m) => sent.push(m) }), withTimeout: (p) => p, isDispatched: () => false })
126
+ notify.notifyLateReject(FULL_SID, board.tasks[0])
127
+ assert.equal(sent.length, 1)
128
+ assert.match(sent[0].content[0].text, /🚨 \[任务看板\] 迟到驳回:r1 你批早了但 Verifier 验出红的/)
129
+ })
130
+
131
+ // ===== ⑤ health banner 只 rejected 亮 =====
132
+ test('⑤ health err banner:lateRejectNote 只在 rejected 路径出现,computeRuntimeHealthHints 如实透出', () => {
133
+ // 无 lateRejectNote(approved 迟到的盘面)→ 无迟到 banner
134
+ const b1 = mkBoard([mkTask({ id: 'h1', status: 'resolved', lateVerdict: { verdict: 'approved', by: 'v', at: 'x' } })])
135
+ const o1 = computeRuntimeHealthHints(b1, { bornAt: Date.now(), lastLateNotice: 'h1|v|approved' }, { capW: 0, alive: false })
136
+ assert.ok(!o1.hints.some(function (x) { return /迟到/.test(x.text) }), 'approved 迟到盘面零迟到 banner')
137
+ // 有 lateRejectNote(rejected 迟到)→ err 级 banner
138
+ const b2 = mkBoard([mkTask({ id: 'h2', status: 'resolved' })])
139
+ const o2 = computeRuntimeHealthHints(b2, { bornAt: Date.now(), lateRejectNote: { taskId: 'h2', at: Date.now() } }, { capW: 0, alive: false })
140
+ const hit = o2.hints.find(function (x) { return /迟到驳回/.test(x.text) })
141
+ assert.ok(hit, 'rejected 迟到 → banner 亮')
142
+ assert.equal(hit.level, 'err')
143
+ assert.match(hit.text, /迟到驳回:h2 你批早了但 Verifier 验出红的/)
144
+ })
@@ -143,6 +143,28 @@ test('组合场景顺序:①冻结 > ②settle > ③幽灵回收', function ()
143
143
  assert.equal(r.consumeReapNote, true)
144
144
  })
145
145
 
146
+ // ===== ⑤b 终止未生效 hint(terminate 假杀根修②,task-mv8jfu1v,事故实证 n-mv8cfndmvfcv)=====
147
+ test('⑤b 终止未生效 hint:terminateFailNote → err 级红条(sticky 不 TTL);无记录不亮', function () {
148
+ var now = Date.now()
149
+ // 无 terminateFailNote → 不亮
150
+ assert.deepEqual(computeRuntimeHealthHints({ tasks: [] }, { bornAt: now }, { capW: 0, now: now }).hints, [])
151
+ // 有记录 → err 级 + 🔴 前缀 + 任务/子会话 id + 补刀指引(与 corruptNote/lateRejectNote 同款 sticky:不消费不 TTL)
152
+ var rt = { bornAt: now, terminateFailNote: { taskId: 'zk1', childId: 'child-zombie', at: now - 60 * 1000 } }
153
+ var r = computeRuntimeHealthHints({ tasks: [] }, rt, { capW: 0, now: now })
154
+ assert.equal(r.hints.length, 1)
155
+ assert.equal(r.hints[0].level, 'err')
156
+ assert.ok(r.hints[0].text.indexOf('🔴') >= 0)
157
+ assert.ok(r.hints[0].text.indexOf('终止未生效') >= 0)
158
+ assert.ok(r.hints[0].text.indexOf('zk1') >= 0)
159
+ assert.ok(r.hints[0].text.indexOf('child-zombie') >= 0)
160
+ assert.ok(r.hints[0].text.indexOf('interrupt_agent') >= 0)
161
+ // sticky:再读仍在(不消费),与 reapNote 的一次性语义相反——事故亮到人来处理
162
+ var r2 = computeRuntimeHealthHints({ tasks: [] }, rt, { capW: 0, now: now + 60 * 1000 })
163
+ assert.equal(r2.hints.length, 1)
164
+ // 缺 taskId 的畸形记录不亮(防御)
165
+ assert.deepEqual(computeRuntimeHealthHints({ tasks: [] }, { bornAt: now, terminateFailNote: {} }, { capW: 0, now: now }).hints, [])
166
+ })
167
+
146
168
  // ===== ⑥ host 挂点源码断言 =====
147
169
  test('⑥ host 挂点源码断言:dispatch 心跳打点 / rpc 接线 / index state 容器', function () {
148
170
  var srcDispatch = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')