dsh-agent-board 1.7.8 → 1.8.0

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.
@@ -480,25 +480,54 @@ test('buildVerifierPrompt: 注入交付物 + 验收脚本 + messages', () => {
480
480
  assert.match(p, /做完了/); assert.match(p, /npm test/); assert.match(p, /board_verdict/); assert.match(p, /方案B/)
481
481
  })
482
482
 
483
- test('buildWorkerPrompt: git 纪律红线(禁还原命令)注入,verifier prompt 不注入(源码断言)', () => {
483
+ test('buildWorkerPrompt: git 纪律红线(禁还原命令 + 精确路径 add)注入,verifier prompt 不注入(源码断言)', () => {
484
484
  const t = mkTask({ id: 'g1' })
485
485
  const p = core.buildWorkerPrompt(t)
486
486
  assert.match(p, /git 纪律红线/)
487
487
  assert.match(p, /禁止 git checkout \/ git restore \/ git reset --hard \/ git clean 等还原命令/)
488
488
  assert.match(p, /会冲掉并行 Worker 与你自己的未提交在途编辑/)
489
489
  assert.match(p, /确实需要干净基线时用 board_report(kind=escalate)上报/)
490
+ // 精确路径 add 条款(task-mv73ylwe;n-mv6xntg1p6i5 实证:并行 Worker 共享暂存区,宽泛 add 吞并他卡在途编辑)
491
+ assert.match(p, /提交时只允许精确路径 git add <本卡 touches 声明的文件>/)
492
+ assert.match(p, /禁止 git add -A \/ git add \. \/ git add 目录/)
493
+ assert.match(p, /并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进你的 commit/)
490
494
  // verifier prompt 不动
491
495
  const pv = core.buildVerifierPrompt(t)
492
496
  assert.doesNotMatch(pv, /git 纪律红线/)
493
497
  assert.doesNotMatch(pv, /reset --hard/)
498
+ assert.doesNotMatch(pv, /精确路径 git add/)
499
+ assert.doesNotMatch(pv, /git add -A/)
494
500
  // 源码级:条款只在 buildWorkerPrompt 体内(切片边界到 buildVerifierPrompt 之前),不在 buildVerifierPrompt 体内
495
501
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
496
502
  const wBody = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
497
503
  assert.ok(wBody.length > 500, 'buildWorkerPrompt 切片成功')
498
504
  assert.match(wBody, /git 纪律红线/)
499
505
  assert.match(wBody, /git reset --hard/)
506
+ assert.match(wBody, /提交时只允许精确路径 git add/)
507
+ assert.match(wBody, /禁止 git add -A/)
500
508
  const vBody = coreSrc.slice(coreSrc.indexOf('export function buildVerifierPrompt'), coreSrc.indexOf('export function buildHookPrompt'))
501
509
  assert.doesNotMatch(vBody, /git 纪律红线/)
510
+ assert.doesNotMatch(vBody, /精确路径 git add/)
511
+ })
512
+
513
+ test('buildWorkerPrompt: 大文件交付纪律(write 主体+edit 续块)注入,verifier prompt 不注入(源码断言)', () => {
514
+ const t = mkTask({ id: 'lf1' })
515
+ const p = core.buildWorkerPrompt(t)
516
+ assert.match(p, /大文件交付纪律/)
517
+ assert.match(p, /单文件交付预估 >8KB(或 spec 含全量文案\/长代码)时,禁止单次 write 写入全量/)
518
+ assert.match(p, /先 write 落主体骨架,再用 edit 追加续块(每块 ≤4KB)逐块落地/)
519
+ // verifier prompt 不动(纪律只约束干活的 Worker)
520
+ const pv = core.buildVerifierPrompt(t)
521
+ assert.doesNotMatch(pv, /大文件交付纪律/)
522
+ assert.doesNotMatch(pv, /每块 ≤4KB/)
523
+ // 源码级:指引段写死在 buildWorkerPrompt 体内(切片边界到 buildVerifierPrompt 之前),不在 buildVerifierPrompt 体内
524
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
525
+ const wBody = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
526
+ assert.ok(wBody.length > 500, 'buildWorkerPrompt 切片成功')
527
+ assert.match(wBody, /大文件交付纪律/)
528
+ assert.match(wBody, /每块 ≤4KB/)
529
+ const vBody = coreSrc.slice(coreSrc.indexOf('export function buildVerifierPrompt'), coreSrc.indexOf('export function buildHookPrompt'))
530
+ assert.doesNotMatch(vBody, /大文件交付纪律/)
502
531
  })
503
532
 
504
533
  // ===== 预研上下文段(瘦身分离形态,task-muvjs392):笔记全文 + 文件清单,不含文件内容本体 =====
@@ -838,6 +867,29 @@ test('pickDispatch: frozen(裁决挂起冻结)不参与任何自动派发',
838
867
  assert.deepEqual(r2.verifs.map(t => t.id).sort(), ['fv', 'nv'])
839
868
  })
840
869
 
870
+ // ===== 容器卡结构性排除(epic 占 worker 槽根修,用户实证:maxWorkers=3 时一张 =====
871
+ // pipeline=full 的 epic 被派出占槽、已就绪任务空等——此前只靠 pipeline=direct 口头约定)=====
872
+ test('pickDispatch: 有子任务的容器卡(epic)结构性不进派发候选,与 pipeline 无关', () => {
873
+ const epi = mkTask({ id: 'epic', pipeline: 'full' }) // 手滑建 full 档 epic(缺省管线,未传 direct)
874
+ const c1 = mkTask({ id: 'c1', parentId: 'epic' })
875
+ const solo = mkTask({ id: 'solo' }) // 无子任务普通卡照常派发(防回归)
876
+ const r = core.pickDispatch(mkBoard([epi, c1, solo]), 5, 0, null)
877
+ assert.deepEqual(r.pendings.map(t => t.id).sort(), ['c1', 'solo']) // epic 被排除,子任务与普卡照派
878
+ // 子任务口径含已归档(与 childStats 同源:parentId 仍指向即算容器)——全归档的 epic 也不复活进池
879
+ const e2 = mkTask({ id: 'e2', pipeline: 'full' })
880
+ const k1 = mkTask({ id: 'k1', parentId: 'e2', status: 'archived' })
881
+ assert.deepEqual(core.pickDispatch(mkBoard([e2, k1]), 5, 0, null).pendings.map(t => t.id), [])
882
+ })
883
+
884
+ test('pickDispatch: 容器卡排除只挡 Worker 派发——epic 收口转 verifying 后 Verifier 派发不受影响', () => {
885
+ // 既有正确行为:子任务全了结后 epic 转 verifying,派 Verifier 验收是真跑(verifs 分支不动)
886
+ const epi = mkTask({ id: 'epic', status: 'verifying', pipeline: 'full' })
887
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
888
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'archived' })
889
+ const r = core.pickDispatch(mkBoard([epi, c1, c2]), 0, 5, null)
890
+ assert.deepEqual(r.verifs.map(t => t.id), ['epic'])
891
+ })
892
+
841
893
  // ===== touches 文件级排他:glob 最小匹配器 =====
842
894
  test('patOverlap: 完全相同路径冲突 + 归一化(\\ → /、去 ./ 前缀与尾 /)', () => {
843
895
  assert.equal(core.patOverlap('src/a.js', 'src/a.js'), true)
@@ -2101,9 +2153,10 @@ test('Token 区统计范围接线:get-tasks 接 range 透传 + fetchTasks 带
2101
2153
  // host:get-tasks 把 args.range 透传给聚合(过滤在 host 做——run 级数据只在 host)
2102
2154
  assert.match(host, /aggregateUsageSummary\(d\.tasks, args && args\.range\)/)
2103
2155
  assert.match(host, /runs\[i\]\.usage/) // run 级留账(范围过滤的数据源,先于本次改动已在)
2104
- // client:轮询带上当前统计范围;空范围不传 field(老 host 调用形态逐字不变)
2156
+ // client:轮询带上当前统计范围;空范围传 {} 占位(rpc 助手 args||{} 归一,host 只读 range 字段,
2157
+ // 与老 host 调用形态等价;task-mv492for 起 {} 上再挂 ifHash 做条件请求)
2105
2158
  assert.match(cli, /var rgSend = activeRange\(\)/)
2106
- assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : undefined/)
2159
+ assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : \{\}/)
2107
2160
  assert.match(cli, /rpc\('get-tasks', rpcArgs\)/)
2108
2161
  // 范围守卫:响应回来时范围已变则丢弃旧范围的聚合,不覆盖新范围的值
2109
2162
  assert.match(cli, /if \(rgKey === \(rgNow\.from \+ '~' \+ rgNow\.to\)\) state\.usageSummary = \(d && d\.usageSummary\) \|\| null/)
@@ -2747,6 +2800,7 @@ test('README 双份:运行区子会话 id 归属标注 + git 纪律红线各
2747
2800
  assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
2748
2801
  assert.ok(r1.indexOf('对应子代理列表同名条目') >= 0, 'README 缺运行区子会话 id 归属标注说明')
2749
2802
  assert.ok(r1.indexOf('git 纪律红线') >= 0, 'README 缺 git 纪律红线说明')
2803
+ assert.ok(r1.indexOf('git add -A') >= 0, 'README 缺精确路径 add 条款说明')
2750
2804
  })
2751
2805
 
2752
2806
  // ===== 归档 tab 数据源口径(反馈 n-muyg3e0m9z7i):归档 tab 空 vs 仪表盘 68 根修 =====
@@ -4226,6 +4280,49 @@ test('task_cancel GUI 按钮门禁:canCancel 三态 + 详情页按钮接线 +
4226
4280
  }
4227
4281
  })
4228
4282
 
4283
+ // ===== 详情面板「取消」消歧(task-mv493xjs,巡检实证 n-mv2turadjv63 误触 task_cancel)=====
4284
+ test('详情面板消歧:「取消」改名「取消任务」+ 危险操作区视觉分层 + ✕ 关闭钮(源码+产物双锁)', () => {
4285
+ const tdsrc = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
4286
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
4287
+ // ① 新文案源码锁定:按钮标签为「取消任务」,不再出现裸「取消」按钮标签(ic('ban', 10), '取消')
4288
+ assert.ok(tdsrc.indexOf("ic('ban', 10), '取消任务'") >= 0, 'task-detail.js 缺「取消任务」新文案')
4289
+ assert.ok(tdsrc.indexOf("ic('ban', 10), '取消')") < 0, 'task-detail.js 仍残留裸「取消」按钮标签')
4290
+ // ② 视觉分层:破坏性按钮独立成区(虚线分隔 + 「危险操作」前缀 + aria-label),与表单按钮组拉开
4291
+ assert.ok(tdsrc.indexOf("'aria-label': '危险操作'") >= 0, 'task-detail.js 缺危险操作区分组')
4292
+ assert.ok(tdsrc.indexOf("borderTop: '1px dashed ' + C.err") >= 0, 'task-detail.js 缺危险区顶部分隔线')
4293
+ assert.ok(tdsrc.indexOf('危险操作') >= 0, 'task-detail.js 缺「危险操作」前缀标签')
4294
+ // ④ ✕ 关闭钮:详情头部右上显性关闭(与「← 返回看板」同行走 state.detailId = null)
4295
+ assert.ok(tdsrc.indexOf("title: '关闭详情面板(返回看板)'") >= 0, 'task-detail.js 缺 ✕ 关闭钮')
4296
+ // ③ 产物重组装:新文案/分层/关闭钮全部进 lib/client.js
4297
+ for (const m of ["ic('ban', 10), '取消任务'", '危险操作', "title: '关闭详情面板(返回看板)'"]) {
4298
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m)
4299
+ }
4300
+ })
4301
+
4302
+ // ===== GUI RPC 操作者回退(task-mv493xjs,巡检次生观察①:流转轨迹 actor 显示 unknown)=====
4303
+ // 面板 HTTP RPC 无 agent 调用栈 → getActorId() 落 'unknown';rpcActor 回退 args 自带 sessionId。
4304
+ test('GUI RPC actor 回退:getActorId=unknown 时流转历史 actor 落会话身份(不再 unknown)', async () => {
4305
+ const t = mkTask({ id: 'g1', status: 'pending' })
4306
+ const board = mkBoard([t])
4307
+ const h = mkRpcHandlers(board, { getActorId: () => 'unknown' })
4308
+ const r = await h['cancel-task']({ taskId: 'g1', reason: 'GUI 误建' })
4309
+ assert.equal(r.ok, true)
4310
+ // actor 不再是 unknown,而是 rpcSessionId(args) 归一出的会话 id(测试桩=FULL_SID)
4311
+ assert.ok(t.history.some(function (x) { return x.to === 'cancelled' && x.actor === FULL_SID }), 'history actor 未回退到会话 id')
4312
+ assert.ok(!t.history.some(function (x) { return x.actor === 'unknown' }), 'history 仍出现 unknown actor')
4313
+ // 正对照:有真实 initiator 时不回退(保持 actor 原值)
4314
+ const t2 = mkTask({ id: 'g2', status: 'pending' })
4315
+ const h2 = mkRpcHandlers(mkBoard([t2]))
4316
+ await h2['cancel-task']({ taskId: 'g2' })
4317
+ assert.ok(t2.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' }), '有 initiator 时 actor 被意外改写')
4318
+ // 归档通道同口径(GUI 高频操作)
4319
+ const t3 = mkTask({ id: 'g3', status: 'resolved' })
4320
+ const h3 = mkRpcHandlers(mkBoard([t3]), { getActorId: () => 'unknown' })
4321
+ const r3 = await h3['archive-task']({ taskId: 'g3' })
4322
+ assert.equal(r3.ok, true)
4323
+ assert.ok(t3.history.some(function (x) { return x.to === 'archived' && x.actor === FULL_SID }), '归档 history actor 未回退到会话 id')
4324
+ })
4325
+
4229
4326
  // ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
4230
4327
  test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
4231
4328
  // RPC 通道:归档末子卡 → 父卡 in-progress → verifying
@@ -4304,9 +4401,10 @@ test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 t
4304
4401
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4305
4402
  assert.equal((rpcSrc.match(/archiveErr\(sid, t\)/g) || []).length, 3)
4306
4403
  // kernel:hash 短路分支存在(hash 相同跳过 tasks 赋值 + notify;老 host 无 hash 恒视为变化)
4404
+ // task-mv492for:tasksChanged 叠加 unchanged 闸门(host 条件响应不含 tasks 时绝不清空列表)
4307
4405
  const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4308
4406
  assert.match(ksrc, /var newHash = \(d && d\.tasksHash\) \|\| ''/)
4309
- assert.match(ksrc, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
4407
+ assert.match(ksrc, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
4310
4408
  assert.match(ksrc, /if \(tasksChanged\) \{[\s\S]*?notify\(\)/)
4311
4409
  })
4312
4410
 
@@ -4488,13 +4586,37 @@ test('isRoot 持久语义:host 侧改读 parentSession(根=无父),防
4488
4586
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4489
4587
  // isRoot 持久判定入口:根会话=无父,不再读活跃 roots() 集
4490
4588
  assert.match(rpcSrc, /async function isRootPersistent\(sid\)/)
4491
- assert.match(rpcSrc, /snap\.header\) return !snap\.header\.parentSession/)
4589
+ // task-mv59nn9m:判据改用 delegationDepth——无父=根,或 parentSession 有值但 delegationDepth===0(fork/seed 会话)也认根
4590
+ assert.match(rpcSrc, /return !hh\.parentSession \|\| \(typeof hh\.delegationDepth === 'number' && hh\.delegationDepth === 0\)/)
4492
4591
  assert.match(rpcSrc, /d\.isRoot = await isRootPersistent\(sid\)/)
4493
4592
  // 指针注释:防抖仍在客户端 kernel.js applyIsRoot,曾确认 true 需连续 3 次 false 才收
4494
4593
  assert.match(rpcSrc, /防抖仍在客户端:kernel\.js applyIsRoot/)
4495
4594
  assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
4496
4595
  })
4497
4596
 
4597
+ // ===== fork 会话认根(task-mv59nn9m,用户报障:ontology_ai「本体数据平台开发」fork 会话看板不可见)=====
4598
+ // 根因:isRootPersistent stat 主源旧判据 `!header.parentSession` 把 fork/seed 会话
4599
+ //(v3→v4 迁移产物:parentSession 有值 + delegationDepth:0 + isSeeded:true)误判非根 → isRoot:false → 看板入口消失。
4600
+ // 修法:判据改 `!h.parentSession || (typeof h.delegationDepth==='number' && h.delegationDepth===0)`;
4601
+ // delegationDepth 缺省(v3 时代老会话无此字段)且有 parentSession 时维持非根保守判定(兼容 v3 时代子代理)。
4602
+ test('isRootPersistent 判据 delegationDepth(行为级):fork 认根 / 子代理非根 / 老形态保守 / 真根不变', async () => {
4603
+ function mkPersistCtx(header) {
4604
+ return { get: function (name) { if (name === 'sessionPersistence') return { stat: async function () { return { header: header } } }; return undefined } }
4605
+ }
4606
+ // ① fork/seed 头(parentSession 有 + delegationDepth 0)→ 根(本卡修复目标)
4607
+ const h1 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 0 }))
4608
+ assert.equal((await h1['get-tasks']({})).isRoot, true)
4609
+ // ② 真子代理头(parentSession 有 + delegationDepth 1)→ 非根
4610
+ const h2 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 1 }))
4611
+ assert.equal((await h2['get-tasks']({})).isRoot, false)
4612
+ // ③ 老形态头(parentSession 有 + delegationDepth 缺省,v3 时代会话)→ 非根(保守判定不放行)
4613
+ const h3 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent' }))
4614
+ assert.equal((await h3['get-tasks']({})).isRoot, false)
4615
+ // ④ 真根(无 parentSession)→ 根
4616
+ const h4 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({}))
4617
+ assert.equal((await h4['get-tasks']({})).isRoot, true)
4618
+ })
4619
+
4498
4620
  // ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
4499
4621
  // 依赖卡 task-muuw4ov7 落地的真实字段契约(本文件上方 hooks 测试段即其口径):
4500
4622
  // epic.hooks = { pre: { enabled, prompt, state, runId, pending? }, post: 同构 };state ∈ idle|running|done|failed;
@@ -5092,6 +5214,82 @@ test('卡3②③:重启 reconcile——存活的续跑 Worker 被找回重挂
5092
5214
  assert.equal(h.runs['k1'], before)
5093
5215
  })
5094
5216
 
5217
+ // ===== 僵尸 run 留档清扫(task-mv22z61v):落定卡 running 无 endedAt 留档关账 + 清卡面认领位 =====
5218
+ test('僵尸 run 留档清扫:落定卡 running 无 endedAt 留档被关账+清位;在飞卡/活跃 rec 不扫;二次运行零改动', async () => {
5219
+ const nowIso = new Date().toISOString()
5220
+ // ① 僵尸 verifier 留档:resolved 卡 + verifierRun 残留指向死会话 + runs[] 里 verifier:running 无 endedAt
5221
+ const zv = mkTask({ id: 'zv', title: '僵尸 verifier 卡', status: 'resolved', verifierRun: 'v-dead', verifierRunAt: '2026-01-01T00:00:00Z',
5222
+ runs: [{ role: 'verifier', id: 'v-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
5223
+ // ② 僵尸 worker 留档:cancelled 卡 + claimedBy 残留指向死会话
5224
+ const zw = mkTask({ id: 'zw', title: '僵尸 worker 卡', status: 'cancelled', claimedBy: 'w-dead', claimedAt: '2026-01-01T00:00:00Z',
5225
+ runs: [{ role: 'worker', id: 'w-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
5226
+ // ③ 已关账留档:outcome 已落定 + endedAt,非 running 条目不该被碰(不误扫非 running 留档)
5227
+ const zc = mkTask({ id: 'zc', title: '已关账卡', status: 'resolved',
5228
+ runs: [{ role: 'worker', id: 'w-ok', at: '2026-01-01T00:00:00Z', model: '', outcome: 'completed', endedAt: '2026-01-01T00:05:00Z' }] })
5229
+ // ④ 在飞卡(in-progress):running 留档是 reconcile/结算通道辖区,绝不误扫(断言②)
5230
+ const inflight = mkTask({ id: 'fly', title: '在飞卡', status: 'in-progress', claimedBy: 'w-alive', claimedAt: nowIso,
5231
+ runs: [{ role: 'worker', id: 'w-alive', at: nowIso, model: '', outcome: 'running', continuable: true }] })
5232
+ // ⑤ 在飞卡(verifying)+ 活跃 rec:双重护栏——status 非落定 + 内存活跃表里还活着,都该跳过(断言② + 无活跃 rec 才扫)
5233
+ const vfy = mkTask({ id: 'vfy', title: '验收中卡', status: 'verifying', verifierRun: 'v-alive', verifierRunAt: nowIso,
5234
+ runs: [{ role: 'verifier', id: 'v-alive', at: nowIso, model: '', outcome: 'running' }] })
5235
+ const board = mkBoard([zv, zw, zc, inflight, vfy])
5236
+ const h = mkUsageDispatch(board)
5237
+ h.runs['vfy'] = { id: 'v-alive', role: 'verifier', taskId: 'vfy', startedAt: Date.now(), model: '', settled: false }
5238
+ await h.dispatch.poolCycle(FULL_SID)
5239
+ // ① 僵尸 verifier 留档:关账(stale-closed + endedAt + note)+ 清 verifierRun 位 + 留一行 history
5240
+ assert.equal(zv.runs[0].outcome, 'stale-closed')
5241
+ assert.ok(zv.runs[0].endedAt, '僵尸 verifier 留档要带 endedAt')
5242
+ assert.equal(zv.runs[0].note, 'host 重启僵尸留档清扫')
5243
+ assert.equal(zv.verifierRun, null)
5244
+ assert.equal('verifierRunAt' in zv, false)
5245
+ assert.equal(zv.history.filter(function (x) { return /僵尸 run 留档已关账/.test(x.note || '') }).length, 1)
5246
+ // ② 僵尸 worker 留档:关账 + 清 claimedBy 位
5247
+ assert.equal(zw.runs[0].outcome, 'stale-closed')
5248
+ assert.ok(zw.runs[0].endedAt)
5249
+ assert.equal(zw.runs[0].note, 'host 重启僵尸留档清扫')
5250
+ assert.equal(zw.claimedBy, null)
5251
+ assert.equal(zw.claimedAt, null)
5252
+ // ③ 已关账留档零改动
5253
+ assert.equal(zc.runs[0].outcome, 'completed')
5254
+ assert.equal(zc.runs[0].endedAt, '2026-01-01T00:05:00Z')
5255
+ assert.equal(zc.runs[0].note, undefined)
5256
+ // ④⑤ 在飞卡 running 留档零改动(断言②:in-progress/verifying 都不扫)
5257
+ assert.equal(inflight.runs[0].outcome, 'running')
5258
+ assert.equal(inflight.runs[0].endedAt, undefined)
5259
+ assert.equal(inflight.claimedBy, 'w-alive')
5260
+ assert.equal(vfy.runs[0].outcome, 'running')
5261
+ assert.equal(vfy.runs[0].endedAt, undefined)
5262
+ assert.equal(vfy.verifierRun, 'v-alive')
5263
+ // ⑤ 幂等:二次运行零改动(关账后 outcome!=running,不再新增 history 行 / 不再刷新 endedAt)
5264
+ const zvEndedAt = zv.runs[0].endedAt
5265
+ const zwEndedAt = zw.runs[0].endedAt
5266
+ const zvHistoryLen = zv.history.length
5267
+ const zwHistoryLen = zw.history.length
5268
+ await h.dispatch.poolCycle(FULL_SID)
5269
+ assert.equal(zv.runs[0].outcome, 'stale-closed')
5270
+ assert.equal(zv.runs[0].endedAt, zvEndedAt)
5271
+ assert.equal(zv.history.length, zvHistoryLen)
5272
+ assert.equal(zw.runs[0].outcome, 'stale-closed')
5273
+ assert.equal(zw.runs[0].endedAt, zwEndedAt)
5274
+ assert.equal(zw.history.length, zwHistoryLen)
5275
+ })
5276
+
5277
+ test('僵尸 run 留档清扫接线(源码级):sweepZombieRuns 定义 + poolCycle 挂点 + 只清扫落定卡门禁', () => {
5278
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5279
+ // ① 清扫函数定义(含「只对落定卡做」状态门禁与「无活跃 rec 才扫」护栏)
5280
+ assert.match(dsp, /async function sweepZombieRuns\(sid, snap\) \{/)
5281
+ assert.match(dsp, /if \(t\.status !== 'resolved' && t\.status !== 'cancelled' && t\.status !== 'archived'\) return/)
5282
+ assert.match(dsp, /if \(rec && !rec\.settled && String\(rec\.id\) === rid\) return/)
5283
+ assert.match(dsp, /r\.outcome = 'stale-closed'/)
5284
+ assert.match(dsp, /r\.endedAt = new Date\(\)\.toISOString\(\)/)
5285
+ assert.match(dsp, /r\.note = 'host 重启僵尸留档清扫'/)
5286
+ assert.match(dsp, /if \(t\.verifierRun === rid\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
5287
+ assert.match(dsp, /if \(t\.claimedBy === rid\) \{ t\.claimedBy = null; t\.claimedAt = null \}/)
5288
+ assert.match(dsp, /return any \? d : null/) // 无改动不写盘(安静板不因清扫产生心跳写盘)
5289
+ // ② poolCycle 挂点:reconcile 之后、幽灵回收之前(落定卡僵尸留档 reconcile/reap 都管不到,须在空闲快进前兜到)
5290
+ assert.match(dsp, /try \{ await sweepZombieRuns\(sid, snap\) \} catch \(e\) \{ console\.error\('\[task-board\] 僵尸留档清扫失败:/)
5291
+ })
5292
+
5095
5293
  test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次性 + 详情页 ↻ 续跑标注 + 文档收尾', () => {
5096
5294
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5097
5295
  const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
@@ -5250,8 +5448,9 @@ test('fetchTasks 设置开关权威纠偏:hash 不变时配置变化补 notify
5250
5448
  const iNotify = src.indexOf('if (cfgDelta || usageDelta) notify()')
5251
5449
  const iEsc = src.indexOf('if (tasksChanged) {\n var newEsc')
5252
5450
  assert.ok(iDelta < iNotify && iEsc > iNotify, '补 notify 必须在配置赋值之后、escalation 分支之前')
5253
- // ④ 既有渲染节约(tasksHash 短路)逐字保持——本次只加不删
5254
- assert.match(src, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
5451
+ // ④ 既有渲染节约(tasksHash 短路)结构保持——task-mv492for 起 tasksChanged 叠加 unchanged 闸门
5452
+ // (host 条件响应 unchanged:true 不含 tasks 全量时保留 state.tasks),hash 比对短路语义不变
5453
+ assert.match(src, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
5255
5454
  assert.match(src, /if \(tasksChanged\) \{\s*\n\s*state\.tasksHash = newHash\s*\n\s*state\.tasks = \(d && d\.tasks\) \|\| \[\]\s*\n\s*\}/)
5256
5455
  // ⑤ 产物接线:client.js 为拼装产物,真源改动必须已重建(产物与源一致由 build-client --check 兜底)
5257
5456
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
@@ -5551,6 +5750,7 @@ test('高优干预①(continuable):rec 无 run 也走宿主投递——hos
5551
5750
  assert.equal(r.ok, true)
5552
5751
  assert.equal(r.delivered, true, 'continuable rec 干预必须真的送达(旧实现整段被跳过 → delivered 恒 false)')
5553
5752
  assert.equal(r.channel, 'steer', 'e2e 断言口径不变:channel 必须仍是 steer')
5753
+ assert.equal(r.state, 'delivered', '实时送达:state=delivered(task-mv6c7cbo 返回体新字段)')
5554
5754
  assert.equal(hostCalls.length, 1)
5555
5755
  assert.deepEqual(sent, [], 'host-protocol 可用时不再重复走 sendMessage(一次干预只投一次)')
5556
5756
  assert.equal(hostCalls[0].p, parent) // 授权者 = 活的直接父 Agent
@@ -5597,6 +5797,7 @@ test('高优干预②(一次性 rec 逐字不变):agent.steer 优先、ste
5597
5797
  board.tasks.push(idle)
5598
5798
  r = await handlers['intervene-agent']({ taskId: 'iv8', message: '无 run 的干预' })
5599
5799
  assert.equal(r.delivered, false); assert.equal(r.channel, '')
5800
+ assert.equal(r.state, 'no-run', '真无 run:state=no-run(GUI 提示色区分字段,task-mv6c7cbo)')
5600
5801
  assert.match(idle.history[idle.history.length - 1].note, /^高优干预: 无 run 的干预(无活跃 run,随下次派发注入)$/)
5601
5802
  })
5602
5803
 
@@ -5656,6 +5857,57 @@ test('高优干预③(continuable 回退链):host 符号不可用 → send
5656
5857
  assert.equal(t6.messages.filter((m) => m.kind === 'intervention').length, 1)
5657
5858
  })
5658
5859
 
5860
+ // ===== 送达提示两态细分(task-mv6c7cbo,实证 n-mv65bhk0mg5w:run 已结算 rec 摘除、claimedBy 仍在续跑窗口,
5861
+ // 旧文案「无活跃 run」技术为真但对用户误导——干预实际随续跑注入)=====
5862
+ test('高优干预⑤(续跑窗口细分):无 rec 但续跑资格/认领位还在 → 文案「随续跑注入」+ state=resume-pending', async () => {
5863
+ // 形态 A:run 刚结算留档 incomplete+continuable(resumeTarget 命中),claimedBy 仍指已结算子会话(续跑窗口原形)
5864
+ const resumeTask = mkTask({
5865
+ id: 'iv9', title: '续跑窗口卡', status: 'in-progress', claimedBy: 'child-done',
5866
+ runs: [{ role: 'worker', id: 'child-done', at: '2026-01-01T00:00:00Z', model: '', outcome: 'incomplete', continuable: true }],
5867
+ })
5868
+ // 形态 B:认领位还在但无任何 runs 留档(resumeTarget 空,纯 claimedBy 重排窗口)
5869
+ const claimed = mkTask({ id: 'iv10', title: '重排窗口卡', status: 'in-progress', claimedBy: 'child-x' })
5870
+ const board = mkBoard([resumeTask, claimed])
5871
+ // resumeTarget 走真实函数(与 index.mjs 生产接线同径:从 createDispatch 返回体取出注入 deps)
5872
+ const dsp = mkContinuableDispatch(mkBoard([]))
5873
+ const handlers = mkRpcHandlers(board, { runsFor: () => ({}), resumeTarget: dsp.dispatch.resumeTarget })
5874
+ // A:resumeTarget 命中 → 续跑窗口文案 + state,且不得再出现失真原文「无活跃 run」
5875
+ let r = await handlers['intervene-agent']({ taskId: 'iv9', message: '窗口期干预A' })
5876
+ assert.equal(r.ok, true); assert.equal(r.delivered, false); assert.equal(r.channel, '')
5877
+ assert.equal(r.state, 'resume-pending')
5878
+ const noteA = resumeTask.history[resumeTask.history.length - 1].note
5879
+ assert.match(noteA, /^高优干预: 窗口期干预A(run 刚结算\/续跑窗口,干预将随续跑注入)$/)
5880
+ assert.ok(!/无活跃 run/.test(noteA), '续跑窗口不得再报「无活跃 run」(task-mv6c7cbo 修复的失真原文): ' + noteA)
5881
+ // 干预原文落 messages——续跑指令经 buildMessages 原文带上(「随续跑注入」语义成立的依据)
5882
+ assert.equal(resumeTask.messages.filter((m) => m.kind === 'intervention').length, 1)
5883
+ assert.equal(resumeTask.messages.filter((m) => m.kind === 'intervention')[0].text, '窗口期干预A')
5884
+ // B:claimedBy 窗口(resumeTarget 空)→ 同一文案与 state
5885
+ r = await handlers['intervene-agent']({ taskId: 'iv10', message: '窗口期干预B' })
5886
+ assert.equal(r.delivered, false); assert.equal(r.state, 'resume-pending')
5887
+ assert.match(claimed.history[claimed.history.length - 1].note, /(run 刚结算\/续跑窗口,干预将随续跑注入)$/)
5888
+ // C:deps 未注入 resumeTarget(老宿主/测试桩降级)→ claimedBy 窗口仍命中,语义只降不炸
5889
+ const handlers2 = mkRpcHandlers(board, { runsFor: () => ({}) })
5890
+ r = await handlers2['intervene-agent']({ taskId: 'iv10', message: '窗口期干预C' })
5891
+ assert.equal(r.state, 'resume-pending')
5892
+ assert.match(claimed.history[claimed.history.length - 1].note, /干预将随续跑注入/)
5893
+ })
5894
+
5895
+ // ===== 两态细分接线(源码级):文案/state 字段/透出/接线不被将来改动悄悄抹掉 =====
5896
+ test('高优干预⑥(源码级):续跑窗口两态文案 + state 字段 + resumeTarget 透出与 rpc 接线在位', () => {
5897
+ const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
5898
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5899
+ const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
5900
+ assert.match(rpcSrc, /(run 刚结算\/续跑窗口,干预将随续跑注入)/) // 续跑窗口文案
5901
+ assert.match(rpcSrc, /(无活跃 run,随下次派发注入)/) // 真无 run 文案不回归
5902
+ assert.match(rpcSrc, /state = 'resume-pending'/)
5903
+ assert.match(rpcSrc, /state = 'no-run'/)
5904
+ assert.match(rpcSrc, /return \{ ok: true, delivered: delivered, channel: channel, state: state \}/) // 返回体带 state
5905
+ assert.match(rpcSrc, /resumeTarget\(t\) !== '' \|\| t\.claimedBy/) // 两态判据(锁内新鲜快照)
5906
+ assert.match(rpcSrc, /var resumeTarget = typeof deps\.resumeTarget === 'function'/) // 缺 dep 降级不炸
5907
+ assert.match(dsp, /resumeTarget: resumeTarget/) // dispatch 返回体透出
5908
+ assert.match(idx, /resumeTarget: dispatch\.resumeTarget/) // 生产接线
5909
+ })
5910
+
5659
5911
  // ===== Bug B ④(源码级):投递点接线不被将来改动悄悄抹掉 =====
5660
5912
  test('高优干预④(源码级):continuable 投递点 = host-protocol 符号键 + sendMessage 回退,且分路在 rec.continuable 上', () => {
5661
5913
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
@@ -5769,3 +6021,154 @@ test('巡检三连②-③:归档「恢复待办」confirm 门禁(list 不弹
5769
6021
  const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5770
6022
  assert.match(built, /window\.confirm\('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token。继续/)
5771
6023
  })
6024
+
6025
+ // ===== set-board-config 模型字段校验(task-mv73ym2k,实证 n-mv6ezj6vary4)=====
6026
+ // ① 非法 id 拒写 + 报错带合法清单 ② 合法 id 通过 ③ 空串清除放行 ④ llm 缺失降级放行
6027
+ // ⑤ 熔断通知分态:UNKNOWN_MODEL → 「配置非法(不在可用清单)」≠「验收连续失败」
6028
+ function mkLlmCtx(modelsByProvider) {
6029
+ // modelsByProvider: { providerId: [{id, name}] }——对齐宿主 llm 服务契约(listProviders/listModels)
6030
+ return {
6031
+ get: function (name) {
6032
+ if (name !== 'llm') return null
6033
+ return {
6034
+ listProviders: function () { return Object.keys(modelsByProvider).map(function (id) { return { id: id, name: id } }) },
6035
+ listModels: async function (p) { return modelsByProvider[p] || [] },
6036
+ }
6037
+ },
6038
+ }
6039
+ }
6040
+ const MOCK_MODELS = { deepseek: [{ id: 'deepseek-chat', name: 'DeepSeek Chat' }, { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner' }], kimi: [{ id: 'kimi-k2', name: 'Kimi K2' }] }
6041
+
6042
+ test('set-board-config 模型校验①:非法 id 拒写 + 报错带合法清单,看板不落脏值', async () => {
6043
+ const board = mkBoard([])
6044
+ const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
6045
+ // 实证原形:显示名「kimi/kimi-k3」抄成 id(合法的是 kimi/kimi-k2)
6046
+ const r = await h['set-board-config']({ key: 'verifierModel', value: 'kimi/kimi-k3' })
6047
+ assert.equal(r.ok, false)
6048
+ assert.match(r.error, /非法模型 id: kimi\/kimi-k3/)
6049
+ assert.match(r.error, /不在当前网关可用清单/)
6050
+ assert.match(r.error, /合法清单(3 个,示前 3 个)/) // 总数 + 截断计数透出
6051
+ assert.match(r.error, /deepseek\/deepseek-chat/) // 清单样例带合法 id
6052
+ assert.match(r.error, /list-models/) // 指引完整清单入口
6053
+ assert.equal(board.verifierModel, undefined) // 拒写:看板字段未被污染
6054
+ // workerModel 同款门禁
6055
+ const r2 = await h['set-board-config']({ key: 'workerModel', value: 'openai/gpt-99' })
6056
+ assert.equal(r2.ok, false)
6057
+ assert.match(r2.error, /非法模型 id/)
6058
+ assert.equal(board.workerModel, undefined)
6059
+ })
6060
+
6061
+ test('set-board-config 模型校验②:合法 id 双字段通过落盘', async () => {
6062
+ const board = mkBoard([])
6063
+ const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
6064
+ const r = await h['set-board-config']({ key: 'verifierModel', value: 'kimi/kimi-k2' })
6065
+ assert.equal(r.ok, true)
6066
+ assert.equal(board.verifierModel, 'kimi/kimi-k2')
6067
+ const r2 = await h['set-board-config']({ key: 'workerModel', value: ' deepseek/deepseek-reasoner ' }) // 首尾空格 trim 后命中
6068
+ assert.equal(r2.ok, true)
6069
+ assert.equal(board.workerModel, 'deepseek/deepseek-reasoner')
6070
+ })
6071
+
6072
+ test('set-board-config 模型校验③:空串/非字符串=清除覆盖,放行不校验', async () => {
6073
+ const board = mkBoard([])
6074
+ board.verifierModel = 'kimi/kimi-k2'
6075
+ const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
6076
+ const r = await h['set-board-config']({ key: 'verifierModel', value: '' })
6077
+ assert.equal(r.ok, true)
6078
+ assert.equal(board.verifierModel, '')
6079
+ const r2 = await h['set-board-config']({ key: 'verifierModel', value: undefined })
6080
+ assert.equal(r2.ok, true)
6081
+ assert.equal(board.verifierModel, '')
6082
+ // 非模型键不受枚举影响(无 llm 也照常写)
6083
+ const h2 = mkRpcHandlers(board)
6084
+ const r3 = await h2['set-board-config']({ key: 'maxWorkers', value: 5 })
6085
+ assert.equal(r3.ok, true)
6086
+ assert.equal(board.maxWorkers, 5)
6087
+ })
6088
+
6089
+ test('set-board-config 模型校验④:llm 服务缺失(老宿主)降级放行,不炸', async () => {
6090
+ const board = mkBoard([])
6091
+ // 显式给「有 get 但 llm 缺席」的老宿主形态(mkRpcHandlers 默认 ctx 无 get 方法,生产 ctx.get 恒存在)
6092
+ const hNoLlm = mkRpcHandlers(board, {}, { get: function () { return null } })
6093
+ const r = await hNoLlm['set-board-config']({ key: 'verifierModel', value: 'whatever/display-name' })
6094
+ assert.equal(r.ok, true) // 降级放行:不校验直接写
6095
+ assert.equal(board.verifierModel, 'whatever/display-name')
6096
+ // list-models 在同形态下报服务不可用(既有契约不变)
6097
+ const lm = await hNoLlm['list-models']({})
6098
+ assert.equal(lm.ok, false)
6099
+ assert.match(lm.error, /llm service unavailable/)
6100
+ })
6101
+
6102
+ test('set-board-config 模型校验④b:provider 枚举失败不阻塞,其余 provider 清单仍可校验', async () => {
6103
+ const board = mkBoard([])
6104
+ const ctxExtra = {
6105
+ get: function (name) {
6106
+ if (name !== 'llm') return null
6107
+ return {
6108
+ listProviders: function () { return [{ id: 'broken', name: 'B' }, { id: 'kimi', name: 'K' }] },
6109
+ listModels: async function (p) { if (p === 'broken') throw new Error('gateway boom'); return [{ id: 'kimi-k2' }] },
6110
+ }
6111
+ },
6112
+ }
6113
+ const h = mkRpcHandlers(board, {}, ctxExtra)
6114
+ const rBad = await h['set-board-config']({ key: 'workerModel', value: 'broken/any' }) // 枚举失败的 provider 清单缺席 → 拒
6115
+ assert.equal(rBad.ok, false)
6116
+ assert.match(rBad.error, /kimi\/kimi-k2/)
6117
+ const rOk = await h['set-board-config']({ key: 'workerModel', value: 'kimi/kimi-k2' })
6118
+ assert.equal(rOk.ok, true)
6119
+ assert.equal(board.workerModel, 'kimi/kimi-k2')
6120
+ })
6121
+
6122
+ // 熔断通知分态行为测试 harness:一次性 verifier run 失败,捕获 pushSysNote 文案 + badModels 落账
6123
+ async function runVerifierFuseCase(errText) {
6124
+ const t = mkTask({ id: 'v-fuse', title: '待验收卡', status: 'verifying' })
6125
+ const board = mkBoard([t])
6126
+ board.verifierModel = 'kimi/kimi-k3' // 已写入的(非法)模型覆盖 → rec.model 非空才会触发熔断
6127
+ const notes = []
6128
+ const runs = {}
6129
+ const activeRuns = { [FULL_SID]: runs }
6130
+ const badModels = {}
6131
+ const listeners = []
6132
+ const ctx = {
6133
+ fs: {}, get: function () { return null }, timer: null,
6134
+ effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
6135
+ on: function (name, fn) { listeners.push({ name: name, fn: fn }); return function () {} },
6136
+ subagents: {
6137
+ list: () => ['mock'],
6138
+ getProvider: () => ({ inheritsParentContext: false }),
6139
+ // spawn 本身成功(半建会话不存在的问题在 provider 层),run.result 随后以给定错误落定失败
6140
+ start: async () => ({ id: 'run-fuse', result: new Promise(function (res, rej) { setImmediate(function () { rej(new Error(errText)) }) }), dispose: async function () {} }),
6141
+ startContinuable: async () => ({ childId: 'child-x', messageId: 'm1' }),
6142
+ },
6143
+ }
6144
+ const dispatch = createDispatch(ctx, { knownSessions: {}, dispatchedEver: {}, badModels: badModels, teamModeCache: {}, activeRuns: activeRuns }, {
6145
+ rt: async () => board, wt: async () => {}, mutateLocked: async (sid, fn) => fn(board), kickCycle: () => {},
6146
+ rootForSession: () => ({ id: FULL_SID }),
6147
+ withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
6148
+ pushSysNote: (sid, text) => { notes.push(text) }, maybeNotify: () => {}, notifyTaskDone: () => {},
6149
+ })
6150
+ await dispatch.poolCycle(FULL_SID)
6151
+ assert.ok(runs['v-fuse'], 'verifier run 应已登记活跃表')
6152
+ // 等 run.result 拒绝 → settleRun → settleVerifier 链落定
6153
+ const flushN = () => new Promise(r => setImmediate(r))
6154
+ for (var i = 0; i < 8; i++) await flushN()
6155
+ return { notes: notes, badModels: badModels, task: t }
6156
+ }
6157
+
6158
+ test('模型熔断分态①:UNKNOWN_MODEL → 「配置非法(不在可用清单)」,不误报连续失败', async () => {
6159
+ const r = await runVerifierFuseCase('spawn failed: UNKNOWN_MODEL: kimi/kimi-k3 (model not configured)')
6160
+ assert.equal(r.badModels[FULL_SID + '|kimi/kimi-k3'], true) // 熔断照常落账(下轮回退父级)
6161
+ assert.equal(r.notes.length, 1)
6162
+ assert.match(r.notes[0], /模型 kimi\/kimi-k3 配置非法(不在可用清单),已熔断回退父级模型/)
6163
+ assert.match(r.notes[0], /list-models/) // 指引重配入口
6164
+ assert.ok(!/验收连续失败/.test(r.notes[0]), 'mistake 类不得误报为 quality 类')
6165
+ assert.equal(r.task.verifyRetries, 1) // 重试计数语义不变
6166
+ })
6167
+
6168
+ test('模型熔断分态②:非 UNKNOWN_MODEL 失败 → 保持「验收连续失败」原文案', async () => {
6169
+ const r = await runVerifierFuseCase('provider timeout after 30s')
6170
+ assert.equal(r.badModels[FULL_SID + '|kimi/kimi-k3'], true)
6171
+ assert.equal(r.notes.length, 1)
6172
+ assert.match(r.notes[0], /模型 kimi\/kimi-k3 验收连续失败,已熔断回退父级模型/)
6173
+ assert.ok(!/配置非法/.test(r.notes[0]))
6174
+ })
@@ -10,7 +10,8 @@
10
10
  // · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
11
11
  // · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
12
12
  // · agents.currentInitiator()/roots()/list()/get()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root;env.setRoots 可清空模拟休眠)
13
- // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话)
13
+ // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话;
14
+ // env.persistedSizes[id]=N 可让快照带 sizeBytes——cleanup 体积主源断言用)
14
15
  // · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
15
16
  // · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
16
17
  // · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
@@ -51,6 +52,7 @@ import { Readable } from 'node:stream'
51
52
  * @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
52
53
  * @property {(list:Object[])=>void} setRoots 改写活跃 root 集(传 [] 模拟休眠根会话/重建窗口)
53
54
  * @property {Object} persistedHeaders 持久会话表 sid → { id, parentSession }(写 parentSession 模拟子会话;缺省根会话 parentSession=undefined)
55
+ * @property {Object} persistedSizes 快照体积表 sid → sizeBytes 数字(cleanup 体积主源断言用;缺省=快照无 sizeBytes → 目录遍历回退)
54
56
  * @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
55
57
  * @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
56
58
  * @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
@@ -194,6 +196,7 @@ export function createMockCtx(opts) {
194
196
  var rootsList = [root] // 活跃 root 集(测试可 env.setRoots([]) 清空模拟「休眠根会话」)
195
197
  var persistedHeaders = {} // 持久会话表 sid → SessionHeader(测试可写 parentSession 模拟子会话/根会话)
196
198
  persistedHeaders[sid] = { id: sid, parentSession: undefined }
199
+ var persistedSizes = {} // 快照体积表 sid → sizeBytes(task-mv39ag1e:cleanup 体积主源断言用;缺省=快照无 sizeBytes,走目录遍历回退)
197
200
  var agents = {
198
201
  currentInitiator: function () { return { id: env.actor } },
199
202
  roots: function () { return rootsList },
@@ -237,7 +240,8 @@ export function createMockCtx(opts) {
237
240
  }
238
241
  if (name === 'sessionPersistence') {
239
242
  return {
240
- stat: async function (id) { var h = persistedHeaders[id]; return h ? { header: h } : undefined },
243
+ // 快照形态对齐官方 SessionPersistenceSnapshot:sizeBytes 仅当后端能廉价提供时才带(未登记=缺省字段)
244
+ stat: async function (id) { var h = persistedHeaders[id]; if (!h) return undefined; var snap = { header: h }; if (typeof persistedSizes[id] === 'number') snap.sizeBytes = persistedSizes[id]; return snap },
241
245
  }
242
246
  }
243
247
  return undefined
@@ -274,6 +278,7 @@ export function createMockCtx(opts) {
274
278
  actor: sid,
275
279
  setRoots: function (list) { rootsList = list || [] }, // 清空/改写活跃 root 集(模拟休眠/重建窗口)
276
280
  persistedHeaders: persistedHeaders, // 持久会话表(sid → { id, parentSession });写 parentSession 模拟子会话
281
+ persistedSizes: persistedSizes, // 快照体积表(sid → sizeBytes 数字);cleanup 体积主源断言用
277
282
 
278
283
  // 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
279
284
  rpc: async function (method, args) {