dsh-agent-board 1.7.0 → 1.7.1

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.
@@ -13,6 +13,9 @@ import * as core from '../lib/core.mjs'
13
13
  import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE } from '../index.mjs'
14
14
  import { aggregateUsageSummary, readRunUsage, findRunLog } from '../index.mjs'
15
15
  import { createRpc } from '../lib/rpc.mjs'
16
+ // no-root 刷屏根治(task-muuf0o7a)专项:会话层幻影板防线 + 派发层 root 闸门直调断言
17
+ import { createSession, isFullSessionId } from '../lib/session.mjs'
18
+ import { createDispatch } from '../lib/dispatch.mjs'
16
19
  // host 端源码拼接(Phase 2 模块化后:薄壳 index.mjs + lib/*.mjs 领域模块),供源码级接线断言
17
20
  const HOST_SOURCES = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/notify.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
18
21
  function hostSrc() { return HOST_SOURCES.map(function (f) { return readFileSync(new URL(f, import.meta.url), 'utf8') }).join('\n') }
@@ -706,12 +709,16 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
706
709
  // ===== create-task 空 description 软警告(E 卡)=====
707
710
  // 轻量 RPC 直调 harness:mock ctx(tools/effect/webServer)+ deps 最小面,直接调 handlers['create-task']
708
711
  // extra 可覆盖默认 deps(如 epic 预检用例注入 pushSysNote 捕获 / sessionCwd 解析根)
712
+ // 幻影板防线(task-muuf0o7a)落地后,create-task 对不完整短 id 直接报错——mock 默认 sid
713
+ // 必须是完整形态(session-xxxx-xxxx-...),否则全量 create-task 用例会被防线拦住。
714
+ const FULL_SID = 'session-test-0000-0000-000000000000'
709
715
  function mkRpcHandlers(board, extra) {
710
716
  const state = { handlers: {}, teamModeCache: {}, feedbackCache: {} }
711
- const ctx = { tools: { register() {} }, effect() {}, webServer: { register() { return () => {} } } }
717
+ const tools = {} // 工具通道捕获:双通道接线测试经 __tools['task_create'].execute(...) 直调
718
+ const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
712
719
  const deps = Object.assign({
713
720
  getActorId: () => 'tester', resolveRoot: (x) => x,
714
- toolSessionId: () => 's1', rpcSessionId: () => 's1',
721
+ toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
715
722
  rootForSession: () => null, deriveWorkMode: () => 'solo', runsFor: () => [],
716
723
  rt: async () => board, mutateLocked: (sid, fn) => fn(board),
717
724
  maybeNotify: () => {}, notifyTaskDone: () => {},
@@ -719,6 +726,7 @@ function mkRpcHandlers(board, extra) {
719
726
  pushSysNote: () => {}, sessionCwd: () => '',
720
727
  }, extra || {})
721
728
  createRpc(ctx, state, deps)
729
+ state.handlers.__tools = tools
722
730
  return state.handlers
723
731
  }
724
732
  const EMPTY_DESC_WARNING = '任务描述为空——Worker 只能凭标题猜需求,建议补一句目标/约束'
@@ -857,6 +865,119 @@ test('update-task RPC: 发布 epic(有子任务缺调研注入)→ pushSysNo
857
865
  assert.equal(r3.ok, false); assert.match(r3.error, /not a draft/); assert.equal(notes2.length, 0)
858
866
  })
859
867
 
868
+ // ===== 调研遵循·host 三件套(task-mutnj3a4):touches→suggestedContextFiles 桥接 + Verifier 归因 + 驳回 hint =====
869
+
870
+ test('suggestContextFiles: glob 跳过 / 锚点剥离 / 存在性过滤 / 去重 / 上限 20 全矩阵', () => {
871
+ // 非数组 / 空条目 / 非字符串 → 空
872
+ assert.deepEqual(core.suggestContextFiles(null, () => true), [])
873
+ assert.deepEqual(core.suggestContextFiles(['', ' ', 42], () => true), [])
874
+ // glob 条目(* ? [ ] { } 任一)跳过;具体文件保留
875
+ assert.deepEqual(core.suggestContextFiles(['src/**', '*.mjs', 'a?.js', 'x/[ab].js', 'x/{a,b}.js', 'lib/a.mjs'], () => true), ['lib/a.mjs'])
876
+ // 锚点 :L 段剥掉后再查存在性,返回剥锚后的相对原样
877
+ const seen = []
878
+ const r = core.suggestContextFiles(['lib/a.mjs:L10-L20'], (p) => { seen.push(p); return true })
879
+ assert.deepEqual(r, ['lib/a.mjs']); assert.deepEqual(seen, ['lib/a.mjs'])
880
+ // 存在性过滤:不存在的剔除
881
+ assert.deepEqual(core.suggestContextFiles(['a.mjs', 'b.mjs'], (p) => p === 'b.mjs'), ['b.mjs'])
882
+ // exists 抛异常视为不存在(宁缺勿滥)
883
+ assert.deepEqual(core.suggestContextFiles(['a.mjs'], () => { throw new Error('x') }), [])
884
+ // exists 缺省 → 退化为只做 glob/锚点过滤(与 epicPrecheck 同口径)
885
+ assert.deepEqual(core.suggestContextFiles(['a.mjs', 'src/**']), ['a.mjs'])
886
+ // 去重(按剥锚后原串)+ 顺序保持
887
+ assert.deepEqual(core.suggestContextFiles(['a.mjs', 'a.mjs:L5', 'b.mjs', 'a.mjs'], () => true), ['a.mjs', 'b.mjs'])
888
+ // 上限 20(与 contextFiles 上限一致)
889
+ const many = []
890
+ for (let i = 0; i < 25; i++) many.push('f' + i + '.mjs')
891
+ assert.equal(core.suggestContextFiles(many, () => true).length, 20)
892
+ })
893
+
894
+ test('attachContextSuggestions: 触发②且有建议 → warning 补尾 + 挂字段;否则形态不变', () => {
895
+ // 触发:warnings 含无调研警告且 touches 有可建议文件
896
+ const out = { ok: true }
897
+ const warns = core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'] }))
898
+ const suggested = core.attachContextSuggestions(out, warns, ['lib/a.mjs'], () => true)
899
+ assert.deepEqual(suggested, ['lib/a.mjs'])
900
+ assert.deepEqual(out.suggestedContextFiles, ['lib/a.mjs'])
901
+ assert.match(warns[0], /可直接 task_update contextFiles 补上/)
902
+ // 不触发①:无「未附调研上下文」警告(已有调研上下文)→ null,out 不挂字段
903
+ const out2 = { ok: true }
904
+ const warns2 = core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'], context: { files: [], notes: 'n' } }))
905
+ assert.equal(core.attachContextSuggestions(out2, warns2, ['lib/a.mjs'], () => true), null)
906
+ assert.equal('suggestedContextFiles' in out2, false)
907
+ // 不触发②:touches 全 glob → null,warning 原文不变(不补尾巴)
908
+ const out3 = { ok: true }
909
+ const warns3 = core.createTaskWarnings(mkTask({ description: 'd', touches: ['src/**'] }))
910
+ const before = warns3.join(';')
911
+ assert.equal(core.attachContextSuggestions(out3, warns3, ['src/**'], () => true), null)
912
+ assert.equal('suggestedContextFiles' in out3, false)
913
+ assert.equal(warns3.join(';'), before)
914
+ })
915
+
916
+ test('create-task 双通道:touches 指向真实文件且无调研上下文 → 挂 suggestedContextFiles + warning 补尾', async () => {
917
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'board-suggest-'))
918
+ fs.writeFileSync(path.join(tmp, 'real.mjs'), '// x')
919
+ const extra = { sessionCwd: () => tmp }
920
+ // RPC 通道:ghost.mjs 不存在、src/** 是 glob → 均剔除,只剩 real.mjs
921
+ const board = mkBoard([])
922
+ const r = await mkRpcHandlers(board, extra)['create-task']({ title: 'T', description: 'd', touches: ['real.mjs', 'ghost.mjs', 'src/**'] })
923
+ assert.equal(r.ok, true)
924
+ assert.deepEqual(r.suggestedContextFiles, ['real.mjs'])
925
+ assert.match(r.warning, /未附调研上下文/); assert.match(r.warning, /可直接 task_update contextFiles 补上/)
926
+ // 工具通道(同口径)
927
+ const board2 = mkBoard([])
928
+ const tools = mkRpcHandlers(board2, extra).__tools
929
+ const r2 = await tools['task_create'].execute({ title: 'T', description: 'd', touches: ['real.mjs'] }, {})
930
+ assert.equal(r2.ok, true)
931
+ assert.deepEqual(r2.suggestedContextFiles, ['real.mjs'])
932
+ assert.match(r2.warning, /可直接 task_update contextFiles 补上/)
933
+ })
934
+
935
+ test('create-task:touches 全 glob 或已有调研上下文 → 不挂 suggestedContextFiles(返回体形态不变)', async () => {
936
+ const board = mkBoard([])
937
+ const r = await mkRpcHandlers(board)['create-task']({ title: 'T', description: 'd', touches: ['src/**'] })
938
+ assert.equal(r.ok, true); assert.match(r.warning, /未附调研上下文/)
939
+ assert.equal('suggestedContextFiles' in r, false)
940
+ assert.ok(!/可直接 task_update/.test(r.warning)) // 无建议时尾巴也不补
941
+ const board2 = mkBoard([])
942
+ const r2 = await mkRpcHandlers(board2)['create-task']({ title: 'T', description: 'd', touches: ['lib/a.mjs'], contextNotes: '已调研' })
943
+ assert.equal(r2.ok, true); assert.equal('warning' in r2, false); assert.equal('suggestedContextFiles' in r2, false)
944
+ })
945
+
946
+ test('verify 驳回 hint:verify-task RPC + task_verify 工具 rejected 附重派提示,approved 不附', async () => {
947
+ // RPC 通道 rejected → 附 hint,驳回语义不变(回 in-progress)
948
+ const board = mkBoard([mkTask({ id: 'v1', status: 'verifying' })])
949
+ const r = await mkRpcHandlers(board)['verify-task']({ taskId: 'v1', verdict: 'rejected', comment: '跑偏了' })
950
+ assert.equal(r.ok, true); assert.match(r.hint, /驳回原因写进 description/); assert.match(r.hint, /新 Worker 没有上一轮记忆/)
951
+ assert.equal(board.tasks[0].status, 'in-progress')
952
+ // RPC 通道 approved → 无 hint 字段
953
+ const board2 = mkBoard([mkTask({ id: 'v2', status: 'verifying' })])
954
+ const r2 = await mkRpcHandlers(board2)['verify-task']({ taskId: 'v2', verdict: 'approved' })
955
+ assert.equal(r2.ok, true); assert.equal('hint' in r2, false)
956
+ // 工具通道 rejected → 同口径附 hint
957
+ const board3 = mkBoard([mkTask({ id: 'v3', status: 'verifying' })])
958
+ const r3 = await mkRpcHandlers(board3).__tools['task_verify'].execute({ taskId: 'v3', verdict: 'rejected', comment: '缺测试' }, {})
959
+ assert.equal(r3.ok, true); assert.match(r3.hint, /驳回原因写进 description/)
960
+ // 工具通道 approved → 无 hint
961
+ const board4 = mkBoard([mkTask({ id: 'v4', status: 'verifying' })])
962
+ const r4 = await mkRpcHandlers(board4).__tools['task_verify'].execute({ taskId: 'v4', verdict: 'approved' }, {})
963
+ assert.equal(r4.ok, true); assert.equal('hint' in r4, false)
964
+ })
965
+
966
+ test('buildVerifierPrompt: 含「立单缺调研」驳回归因条款', () => {
967
+ const p = core.buildVerifierPrompt(mkTask({ id: 'tx', status: 'verifying' }), '')
968
+ assert.match(p, /立单缺调研/)
969
+ assert.match(p, /驳回热点统计/)
970
+ })
971
+
972
+ test('调研遵循接线:双通道 attachContextSuggestions / 驳回 hint 双挂 / Verifier 归因条款(源码级断言)', () => {
973
+ const src = hostSrc()
974
+ assert.equal((src.match(/attachContextSuggestions\(/g) || []).length, 2) // task_create 工具 + create-task RPC
975
+ assert.equal((src.match(/\.hint = REJECT_REDISPATCH_HINT/g) || []).length, 2) // task_verify 工具 + verify-task RPC
976
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
977
+ assert.match(coreSrc, /立单缺调研/) // Verifier prompt 归因条款在位
978
+ assert.match(coreSrc, /export function suggestContextFiles/)
979
+ })
980
+
860
981
  test('调研门禁接线:双通道 warning 调用 / epic 预检投递 / 读包失败落卡(源码级轻量断言)', () => {
861
982
  const src = hostSrc()
862
983
  assert.equal((src.match(/createTaskWarnings\(t\)/g) || []).length, 2) // task_create 工具 + create-task RPC
@@ -999,7 +1120,7 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
999
1120
  // 候选教训两处触发:Verifier 驳回(文本通道 + 工具通道 + GUI RPC)与主窗口仲裁结论
1000
1121
  assert.match(host, /pushRejectLesson\(d, t, vsecs\.verifySummary \|\| trimmed, t\.verification\.at\)/)
1001
1122
  assert.match(host, /if \(!approved\) pushRejectLesson\(d, t, \(args\.summary \|\| ''\)/)
1002
- assert.match(host, /if \(args\.verdict === 'rejected'\) pushRejectLesson\(d, t, args\.comment\)/)
1123
+ assert.match(host, /if \(args\.verdict === 'rejected'\) \{ pushRejectLesson\(d, t, args\.comment\); r\.hint = REJECT_REDISPATCH_HINT \}/) // verify-task RPC:候选教训 + 驳回重派 hint(task-mutnj3a4)同分支挂载
1003
1124
  assert.match(host, /pushArbitrationLesson\(d, t, escQ, answer \|\| '', arbAt\)/)
1004
1125
  // 生成前一律过 feedbackEnabled 总开关(关掉 = 不生成、不推)
1005
1126
  assert.equal((host.match(/if \(!cfg\(d\)\.feedbackEnabled\) return false/g) || []).length, 2)
@@ -1136,9 +1257,9 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
1136
1257
  assert.match(host, /claimApply\(d, t, 'spawn-pending', 'dispatch'\); if \(parentKickOnDispatch\(d, t\)\)/)
1137
1258
  // get-tasks 现算 childStats(零存储)
1138
1259
  assert.match(host, /d\.childStats = aggregateChildStats\(d\.tasks\)/)
1139
- // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)扩展三个纯函数
1260
+ // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、tasksHash(task-mutrtwin)扩展
1140
1261
  assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT \} = core/)
1141
- assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote \} = core/)
1262
+ assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash \} = core/)
1142
1263
  // 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;core.mjs 不在 hostSrc 清单,单独读)
1143
1264
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1144
1265
  assert.match(coreSrc, /if \(verdict === 'approved' && isb\(t\)\) \{ var p = checkParentAuto\(d, t\)/)
@@ -1177,3 +1298,245 @@ test('调研门禁 UI 接线:卡片「⚠️ 无调研」徽章 + 详情「调
1177
1298
  assert.match(cli, /if \(r && r\.warning\)/) // 创建表单消费 create-task 响应的 warning 字段
1178
1299
  assert.match(cli, /'⚠️ ' \+ warn/) // warning 原文黄色行展示(C.warn)
1179
1300
  })
1301
+
1302
+ // ===== 反馈修复(task-mutrtwin):僵尸 epic 归档放行 + get-tasks tasksHash =====
1303
+ // ① archive-task 对「无活跃 run 的 in-progress」放行(parentKick 僵尸态出清,反馈 n-mutma3mmwceq);
1304
+ // ② get-tasks 附 tasksHash(core 纯函数 djb2),kernel hash 相同跳渲染(反馈 n-mut9rzs2mkhg)。
1305
+
1306
+ test('tasksHash: 同输入同 hash(纯函数稳定;JSON 重解析的等价形态 hash 相同)', () => {
1307
+ const a = [mkTask({ id: 'a', title: '甲' }), mkTask({ id: 'b', status: 'in-progress' })]
1308
+ assert.equal(core.tasksHash(a), core.tasksHash(a))
1309
+ // 跨轮询 rt() 重新 JSON.parse 的同内容对象 → hash 相同(短路生效的前提)
1310
+ assert.equal(core.tasksHash(a), core.tasksHash(JSON.parse(JSON.stringify(a))))
1311
+ assert.equal(core.tasksHash([]), core.tasksHash([]))
1312
+ assert.equal(core.tasksHash(undefined), core.tasksHash(null)) // 非数组一律按空板兜底
1313
+ })
1314
+
1315
+ test('tasksHash: 关键字段变 → hash 变;非关键字段(history/messages/usage)变 → hash 不变', () => {
1316
+ const h0 = core.tasksHash([mkTask({ id: 'a' })])
1317
+ // 关键字段逐一变化(驱动列表/详情渲染的字段)
1318
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', status: 'in-progress' })]), h0)
1319
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', title: '改名' })]), h0)
1320
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', priority: 'high' })]), h0)
1321
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', lastError: 'boom' })]), h0)
1322
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', lastProgress: { text: '过半', at: 'x' } })]), h0)
1323
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', resolvedAt: '2026-01-02T00:00:00Z' })]), h0)
1324
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', frozen: true })]), h0)
1325
+ // escalation 必须入 hash:board_report escalate 只写 escalation/messages/history、
1326
+ // 不动其他任何字段——漏掉它「新歧义自动弹开面板」会被 hash 短路吞掉
1327
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', escalation: { question: '歧义', at: 'x', by: 'w' } })]), h0)
1328
+ // 非关键字段不动 hash(不驱动列表渲染,参与会让结算写账/进展消息引发空渲染抖动)
1329
+ const t1 = mkTask({ id: 'a' }); t1.history.push({ from: 'pending', to: 'in-progress', timestamp: 'x', actor: 'y', note: '' })
1330
+ assert.equal(core.tasksHash([t1]), h0)
1331
+ const t2 = mkTask({ id: 'a' }); t2.messages.push({ kind: 'progress', text: 'p', at: 'x', by: 'w' })
1332
+ assert.equal(core.tasksHash([t2]), h0)
1333
+ const t3 = mkTask({ id: 'a' }); t3.usage = { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10, runs: 1, models: {} }
1334
+ assert.equal(core.tasksHash([t3]), h0)
1335
+ })
1336
+
1337
+ test('tasksHash: 顺序有关(数组序变化 → hash 变;看板按数组序渲染,口径写清)', () => {
1338
+ const a = mkTask({ id: 'a' }), b = mkTask({ id: 'b' })
1339
+ assert.notEqual(core.tasksHash([a, b]), core.tasksHash([b, a]))
1340
+ })
1341
+
1342
+ test('archive-task RPC: resolved/cancelled 放行(既有行为不破)', async () => {
1343
+ const board = mkBoard([mkTask({ id: 'r1', status: 'resolved' }), mkTask({ id: 'c1', status: 'cancelled' })])
1344
+ const h = mkRpcHandlers(board)
1345
+ assert.equal((await h['archive-task']({ taskId: 'r1' })).ok, true)
1346
+ assert.equal((await h['archive-task']({ taskId: 'c1' })).ok, true)
1347
+ assert.equal(board.tasks[0].status, 'archived')
1348
+ assert.equal(board.tasks[1].status, 'archived')
1349
+ })
1350
+
1351
+ test('archive-task RPC: in-progress 无活跃 run 放行(parentKick 僵尸 epic 出清)', async () => {
1352
+ // 僵尸态还原:direct epic 被 parentKickOnDispatch 推进到 in-progress,子任务已归档、无 run、claimedBy=null
1353
+ const zombie = mkTask({ id: 'z1', status: 'in-progress', claimedBy: null, pipeline: 'direct' })
1354
+ const board = mkBoard([zombie])
1355
+ const h = mkRpcHandlers(board, { hasActiveRun: () => false })
1356
+ const r = await h['archive-task']({ taskId: 'z1' })
1357
+ assert.equal(r.ok, true); assert.equal(zombie.status, 'archived'); assert.ok(zombie.archivedAt)
1358
+ })
1359
+
1360
+ test('archive-task RPC: in-progress 有活跃 run 拒绝;已 settled run 放行;未注入 hasActiveRun 按活跃兜底', async () => {
1361
+ // 有活跃 run → 拒绝(不能归在跑的任务)
1362
+ let t = mkTask({ id: 'a1', status: 'in-progress' })
1363
+ let h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => true })
1364
+ let r = await h['archive-task']({ taskId: 'a1' })
1365
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/); assert.equal(t.status, 'in-progress')
1366
+ // 有 run 记录但已 settled(finish→settleRun 过渡窗口,Worker 已结束)→ 放行
1367
+ // (hasActiveRun 的 !settled 口径由 index.mjs 实现,此处 mock 其结论)
1368
+ t = mkTask({ id: 'a2', status: 'in-progress' })
1369
+ h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
1370
+ r = await h['archive-task']({ taskId: 'a2' })
1371
+ assert.equal(r.ok, true); assert.equal(t.status, 'archived')
1372
+ // deps 未注入 hasActiveRun → 保守兜底视为有活跃 run → 拒绝(保持旧门禁行为,不误放在跑任务)
1373
+ t = mkTask({ id: 'a3', status: 'in-progress' })
1374
+ h = mkRpcHandlers(mkBoard([t]))
1375
+ r = await h['archive-task']({ taskId: 'a3' })
1376
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
1377
+ // 其余状态(verifying 等)仍拒绝——放行面只扩到「无活跃 run 的 in-progress」
1378
+ t = mkTask({ id: 'a4', status: 'verifying' })
1379
+ h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
1380
+ r = await h['archive-task']({ taskId: 'a4' })
1381
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
1382
+ })
1383
+
1384
+ test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放行/活跃拒绝)', async () => {
1385
+ let t = mkTask({ id: 'z2', status: 'in-progress', claimedBy: null })
1386
+ let h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
1387
+ let r = await h.__tools['task_archive'].execute({ taskId: 'z2' }, {})
1388
+ assert.equal(r.ok, true); assert.equal(t.status, 'archived')
1389
+ t = mkTask({ id: 'z3', status: 'in-progress' })
1390
+ h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => true })
1391
+ r = await h.__tools['task_archive'].execute({ taskId: 'z3' }, {})
1392
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
1393
+ })
1394
+
1395
+ test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 tasksHash + kernel 短路分支(源码级)', () => {
1396
+ const host = hostSrc()
1397
+ // index.mjs:hasActiveRun 定义(runsFor 表有记录且 !settled 即活跃)并注入 createRpc deps
1398
+ assert.match(host, /function hasActiveRun\(sid, taskId\) \{ var rec = session\.runsFor\(sid\)\[taskId\]; return !!\(rec && !rec\.settled\) \}/)
1399
+ assert.match(host, /hasActiveRun: hasActiveRun,/)
1400
+ // rpc.mjs:get-tasks 响应附 tasksHash(现算不落盘);archiveErr 门禁 = 定义1 + RPC1 + 工具1
1401
+ assert.match(host, /d\.tasksHash = tasksHash\(d\.tasks\)/)
1402
+ const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
1403
+ assert.equal((rpcSrc.match(/archiveErr\(sid, t\)/g) || []).length, 3)
1404
+ // kernel:hash 短路分支存在(hash 相同跳过 tasks 赋值 + notify;老 host 无 hash 恒视为变化)
1405
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
1406
+ assert.match(ksrc, /var newHash = \(d && d\.tasksHash\) \|\| ''/)
1407
+ assert.match(ksrc, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
1408
+ assert.match(ksrc, /if \(tasksChanged\) \{[\s\S]*?notify\(\)/)
1409
+ })
1410
+
1411
+ // ===== 无障碍两件套(task-mutrubay,反馈 n-mut9rzpyc7p1):卡片键盘可达 + 详情页「流转到」按钮组(源码级断言)=====
1412
+ test('无障碍接线:卡片 role/tabIndex/aria-label/onKeyDown/焦点框 + 详情页流转按钮组(源码级)', () => {
1413
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1414
+ // ① 卡片键盘可达:button 角色 + Tab 序 + 读屏标签(标题+状态)+ Enter/Space 与点击共用同一激活函数 + 主题色焦点框
1415
+ assert.match(cli, /role: 'button', tabIndex: 0, 'aria-label': t\.title \+ '(' \+ \(statusLabels\[t\.status\] \|\| t\.status\) \+ ')'/)
1416
+ assert.match(cli, /onKeyDown: function \(e\) \{ if \(e\.key === 'Enter' \|\| e\.key === ' '\) \{ e\.preventDefault\(\); activate\(\) \} \}/)
1417
+ assert.match(cli, /onClick: activate,/) // 点击与键盘同一条激活路径(多选=切换选中,否则开详情)
1418
+ assert.match(cli, /outline: isCardFocus\(t\.id\) \? '2px solid ' \+ C\.brand : 'none'/) // 主题色焦点框(非浏览器默认蓝框)
1419
+ assert.match(cli, /function isCardFocus\(id\)/) // 焦点态走 state 现算(轮询重渲染不丢焦点框)
1420
+ // ② 详情页「流转到」按钮组:合法迁移表 + 组容器(读屏可报组名)+ 四状态按钮文案
1421
+ assert.match(cli, /var FLOW_DEF = \{/)
1422
+ assert.match(cli, /role: 'group', 'aria-label': '状态流转'/)
1423
+ assert.match(cli, /'⇄ 流转到:'/)
1424
+ assert.match(cli, /'▶ 开始处理'/); assert.match(cli, /'✅ 提交验收'/); assert.match(cli, /'⛔ 标记阻塞'/)
1425
+ assert.match(cli, /'↩ 重投待办'/); assert.match(cli, /'✔ 验收通过'/); assert.match(cli, /'↩ 驳回重投'/)
1426
+ // 迁移通道与拖拽 transition() 同 RPC 族(host 门禁不变);失败原因 ⚠️ 回显 actionMsg 行
1427
+ assert.match(cli, /function flowTo\(to\)/)
1428
+ assert.match(cli, /p = rpc\('claim-task', \{ taskId: task\.id \}\)/)
1429
+ assert.match(cli, /p = rpc\('update-task', \{ taskId: task\.id, resetToPending: true \}\)/)
1430
+ assert.match(cli, /p = rpc\('resolve-task', \{ taskId: task\.id, status: 'verifying', resolution: res \}\)/)
1431
+ assert.match(cli, /p = rpc\('verify-task', \{ taskId: task\.id, verdict: 'approved' \}\)/)
1432
+ assert.match(cli, /setActionMsg\('⚠️ 流转失败:' \+ \(r\.error \|\| '未知错误'\)\)/)
1433
+ })
1434
+
1435
+ // ===== no-root 刷屏根治(task-muuf0o7a):poolCycle root 闸门 + 短 id 幻影板防线 =====
1436
+ // 事故链:幻影板 tasks-cc24eb5c.json(裸短 id 建出)滞留 pending 卡 → poolCycle 每 15s
1437
+ // pickDispatch 成功→占位 claim→spawnOneShot 才发现无活 root→console.error→占位超时回收
1438
+ // →下轮再来,永久 spam。根治 = ①poolCycle 入口 root 闸门(无 root 不派发不写盘零日志)
1439
+ // + ②touchSession/create-task 拒绝不完整短 id(import- 前缀放行)。
1440
+
1441
+ test('isFullSessionId:裸短 id/空值/unknown 拒绝;完整 root id 与 import- 前缀放行', () => {
1442
+ assert.equal(isFullSessionId('cc24eb5c'), false) // 裸短 id(本次幻影板元凶)
1443
+ assert.equal(isFullSessionId(''), false)
1444
+ assert.equal(isFullSessionId(null), false)
1445
+ assert.equal(isFullSessionId(undefined), false)
1446
+ assert.equal(isFullSessionId('unknown'), false)
1447
+ assert.equal(isFullSessionId('abcdefghij-k'), false) // 长度 <20
1448
+ assert.equal(isFullSessionId('abcdefghijklmno-pqrst'), false) // ≥20 但只有一个 '-'
1449
+ assert.equal(isFullSessionId('-abcdefghij-klmnopqr'), false) // 首字符即 '-' 不算结构
1450
+ assert.equal(isFullSessionId('session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6'), true)
1451
+ assert.equal(isFullSessionId('import-sess_df0837fa-1'), true) // import- 前缀板合法放行
1452
+ })
1453
+
1454
+ test('touchSession:不完整短 id 不注册进已知会话集合(幻影板不进心跳轮询)', () => {
1455
+ const known = {}
1456
+ const session = createSession({}, { knownSessions: known, feedbackCache: {}, activeRuns: {}, dispatchedEver: {} })
1457
+ session.touchSession('cc24eb5c') // 裸短 id → 拒绝
1458
+ session.touchSession('unknown') // 既有排除项不变
1459
+ session.touchSession(null)
1460
+ session.touchSession('session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6') // 完整 id → 注册
1461
+ session.touchSession('import-sess_df0837fa-1') // import- 前缀 → 放行
1462
+ assert.deepEqual(Object.keys(known).sort(), ['import-sess_df0837fa-1', 'session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6'].sort())
1463
+ })
1464
+
1465
+ // poolCycle 直调 harness:ctx 不给 timer(15s 心跳 IIFE 跳过),deps 全 mock,计数读/写/日志
1466
+ function mkDispatch(board, over) {
1467
+ const counters = { reads: 0, writes: 0, errs: [] }
1468
+ const dispatch = createDispatch(
1469
+ // 创建期无条件触达:effect(卸载清理注册)/ get('systemPrompt' 引导段);不给 timer/agents/subagents
1470
+ { fs: {}, effect: function () {}, get: function () { return null } },
1471
+ { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} },
1472
+ Object.assign({
1473
+ rt: async () => { counters.reads++; return board },
1474
+ wt: async () => { counters.writes++ },
1475
+ mutateLocked: async (sid, fn) => { counters.writes++; return fn(board) },
1476
+ kickCycle: () => {},
1477
+ rootForSession: () => undefined, // 默认无活 root(幻影板/死会话场景)
1478
+ sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => ({}), feedbackOn: () => true,
1479
+ pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
1480
+ }, over || {}))
1481
+ return { dispatch, counters }
1482
+ }
1483
+
1484
+ test('poolCycle root 闸门:无活 root → 直接返回 undefined,不读盘/不写盘/零日志(根治 15s 刷屏)', async () => {
1485
+ const { dispatch, counters } = mkDispatch(mkBoard([mkTask()])) // 板上有 pending 卡也不进循环
1486
+ const origErr = console.error
1487
+ console.error = function () { counters.errs.push(Array.prototype.join.call(arguments, ' ')) }
1488
+ try {
1489
+ const r = await dispatch.poolCycle('cc24eb5c')
1490
+ assert.equal(r, undefined) // 调用方(kickCycle/15s 心跳)忽略返回值,签名兼容
1491
+ assert.equal(counters.reads, 0) // rt 都没跑:零读盘
1492
+ assert.equal(counters.writes, 0) // 零写盘
1493
+ assert.deepEqual(counters.errs, []) // 零日志
1494
+ } finally { console.error = origErr }
1495
+ })
1496
+
1497
+ test('poolCycle root 闸门:有活 root → 正常进循环(空板走空闲快进返回 snap,不写盘)', async () => {
1498
+ const board = mkBoard([])
1499
+ const { dispatch, counters } = mkDispatch(board, { rootForSession: () => ({ id: FULL_SID }) })
1500
+ const r = await dispatch.poolCycle(FULL_SID)
1501
+ assert.ok(r && Array.isArray(r.tasks)) // 空闲快进返回 snap(闸门未误伤正常路径)
1502
+ assert.equal(counters.reads, 1)
1503
+ assert.equal(counters.writes, 0) // 空板无残留 poolStatus/dispatchInfo → 快进不写盘
1504
+ })
1505
+
1506
+ test('create-task RPC:不完整短 id sessionId → 报错不建板;import- 前缀与正常路径放行', async () => {
1507
+ // rpcSessionId 覆写为「尊重显式 sessionId 入参」(生产行为:args.sessionId 优先于 actor 归一)
1508
+ const honorSid = { rpcSessionId: (args) => (args && args.sessionId) || FULL_SID }
1509
+ // ① 裸短 id → 拒绝且不落任务(防幻影板)
1510
+ const board = mkBoard([])
1511
+ const r = await mkRpcHandlers(board, honorSid)['create-task']({ title: 'T', description: 'd', sessionId: 'cc24eb5c' })
1512
+ assert.equal(r.ok, false)
1513
+ assert.match(r.error, /sessionId 不完整/)
1514
+ assert.equal(board.tasks.length, 0)
1515
+ // ② import- 前缀板 → 放行(导入会话是合法板,别误伤)
1516
+ const board2 = mkBoard([])
1517
+ const r2 = await mkRpcHandlers(board2, honorSid)['create-task']({ title: 'T', description: 'd', sessionId: 'import-sess_df0837fa-1' })
1518
+ assert.equal(r2.ok, true)
1519
+ assert.equal(board2.tasks.length, 1)
1520
+ // ③ 不传 sessionId(正常路径,归一为完整 root id)→ 放行,返回体形态不变
1521
+ const board3 = mkBoard([])
1522
+ const r3 = await mkRpcHandlers(board3, honorSid)['create-task']({ title: 'T', description: 'd' })
1523
+ assert.equal(r3.ok, true)
1524
+ assert.equal(board3.tasks.length, 1)
1525
+ })
1526
+
1527
+ test('no-root 刷屏根治接线断言(源码级):闸门/防线/兜底注释均在位', () => {
1528
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
1529
+ const ses = readFileSync(new URL('../lib/session.mjs', import.meta.url), 'utf8')
1530
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
1531
+ // ① poolCycle 入口即 root 闸门(闸门必须在 rt 读盘之前——不读盘才谈得上零 IO)
1532
+ assert.match(dsp, /async function poolCycle\(sid\) \{[\s\S]{0,900}if \(!rootForSession\(sid\)\) return undefined[\s\S]{0,200}var snap = await rt\(sid\)/)
1533
+ // spawnOneShot 的 console.error 兜底保留 + 注释说明闸门在上游
1534
+ assert.match(dsp, /console\.error\('\[task-board\] no root agent for session ' \+ sid \+ ', skip spawn'\)/)
1535
+ assert.match(dsp, /poolCycle 入口已有 root 存活早闸门/)
1536
+ // ② touchSession 注册口接入 isFullSessionId;纯函数出口 + rpc.mjs 直引
1537
+ assert.match(ses, /export function isFullSessionId\(sid\)/)
1538
+ assert.match(ses, /sid !== 'unknown' && isFullSessionId\(sid\)\) knownSessions\[sid\]/)
1539
+ assert.match(rpc, /import \{ isFullSessionId \} from '\.\/session\.mjs'/)
1540
+ // create-task handler 校验在 mutateLocked 之前(拒绝时不建板)
1541
+ assert.match(rpc, /handle\('create-task', async function \(args\) \{ var sid = rpcSessionId\(args\);[\s\S]{0,400}if \(!isFullSessionId\(sid\)\) return \{ ok: false, error: 'sessionId 不完整[\s\S]{0,120}return mutateLocked/)
1542
+ })