dsh-agent-board 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import os from 'node:os'
8
8
  import path from 'node:path'
9
9
  import zlib from 'node:zlib'
10
10
  import { spawnSync } from 'node:child_process'
11
+ import { fileURLToPath } from 'node:url'
11
12
  import * as core from '../lib/core.mjs'
12
13
  // 粒度治理(软闸门)住在 lib/policy.mjs(策略层,纯函数)、usage 聚合住在 lib/usage.mjs,
13
14
  // 均由 index.mjs 薄壳 re-export(对外契约不变),这里仍从 index.mjs 导入直接断言
@@ -19,7 +20,8 @@ import { createSession, isFullSessionId } from '../lib/session.mjs'
19
20
  import { createDispatch } from '../lib/dispatch.mjs'
20
21
  import { createNotify } from '../lib/notify.mjs'
21
22
  // host 端源码拼接(Phase 2 模块化后:薄壳 index.mjs + lib/*.mjs 领域模块),供源码级接线断言
22
- 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']
23
+ // 拆分第 6 刀(task-mvt8c1yx-6):角色结算搬入 lib/settle.mjs——拼接清单补入 settle.mjs(接线断言随源码重指)
24
+ const HOST_SOURCES = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/notify.mjs', '../lib/dispatch.mjs', '../lib/settle.mjs', '../lib/rpc.mjs']
23
25
  function hostSrc() { return HOST_SOURCES.map(function (f) { return readFileSync(new URL(f, import.meta.url), 'utf8') }).join('\n') }
24
26
 
25
27
  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 || {}) }
@@ -1040,6 +1042,82 @@ test('parsePlanFiles: 剥行尾注释——全角/半角括号尾巴 + 「 —
1040
1042
  assert.deepEqual(core.planOutsideTouches(['lib/core.mjs'], core.parsePlanFiles('计划改动:lib/core.mjs(L75-L91 语义注释)')), [])
1041
1043
  })
1042
1044
 
1045
+ // 像路径判定(task-mvqfl7in,n-mvqfiofr0kt9 误报实证:注释剥离只救带括号/破折号的说明行,
1046
+ // 无路径特征的纯散文行仍整行被当文件参与 touches 比对 → 假越界警示,Worker 被迫改纯路径清单才过)
1047
+ test('parsePlanFiles: 像路径判定——纯散文行直接丢弃;路径行/裸扩展名行/反斜杠行/尾斜杠行不受影响;混合清单只留路径;全散文=空清单重报语义', () => {
1048
+ // 纯散文行(无 / 无 \ 无扩展名)不进清单——昨天的实证误报形态:说明文字整行被当文件比对
1049
+ assert.deepEqual(core.parsePlanFiles('计划改动:这是一段纯说明文字'), [])
1050
+ assert.deepEqual(core.parsePlanFiles('计划改动:\n- 调研文件清单\n- 只动解析不动闸口比对'), [])
1051
+ // 裸扩展名行(无 / 但以代码扩展名结尾)不受影响——像路径判定或一条件的扩展名分支(宽正则)
1052
+ assert.deepEqual(core.parsePlanFiles('计划改动:README.md'), ['README.md'])
1053
+ assert.deepEqual(core.parsePlanFiles('计划改动:plan.mjs、CHANGELOG.md'), ['plan.mjs', 'CHANGELOG.md'])
1054
+ // Windows 反斜杠目录行(无扩展名,归一前含分隔符证据)不受影响——判定在归一前做
1055
+ assert.deepEqual(core.parsePlanFiles('计划改动:D:\\deepseek-work\\lib'), ['D:/deepseek-work/lib'])
1056
+ // 尾部斜杠目录行(`lib/` 归一为裸目录名)不受影响——分隔符证据在归一前,与既有归一断言同口径
1057
+ assert.deepEqual(core.parsePlanFiles('计划改动:lib/'), ['lib'])
1058
+ // 混合清单:散文行丢弃、路径行保留,保序去重
1059
+ assert.deepEqual(core.parsePlanFiles('计划改动:\n- 调研文件清单\n- lib/core.mjs\n- 闸口解析补刀\n- lib/core.mjs'), ['lib/core.mjs'])
1060
+ // 误报直接复现:路径行+散文行同报,散文行不再触发假越界
1061
+ assert.deepEqual(core.planOutsideTouches(['lib/core.mjs'], core.parsePlanFiles('计划改动:lib/core.mjs(语义注释)\n这是一段纯说明文字')), [])
1062
+ // 全散文条目全被丢光 = 空数组 → 调用方既有「有前缀但零路径」重报提示语义(rpc.mjs planGateApply 空清单分支)
1063
+ assert.deepEqual(core.planOutsideTouches(['lib/**'], core.parsePlanFiles('计划改动:\n- 纯说明行一\n- 纯说明行二')), [])
1064
+ })
1065
+
1066
+ // 像路径判定收紧(task-mvr44s0y,n-mvqfiofr0kt9 追加样本):全角标点拆段 + 含路径碎片散文段两类残留根治
1067
+ test('parsePlanFiles: 收紧——全角分隔长说明段不进清单;含路径碎片的散文段整条丢弃(选定语义:取前缀或丢弃二选一取丢弃);路径优先混合行截到路径前缀;引号包裹路径含空格不受影响', () => {
1068
+ // ① 全角标点拆段(n-mvqfiofr0kt9 追加样本):中文逗号分隔的长说明段(散文在前、路径碎片在后)整段不进清单
1069
+ assert.deepEqual(core.parsePlanFiles('计划改动:本次重构 lib/core.mjs 的 parsePlanFiles,补全测试断言,顺带更新 README'), [])
1070
+ assert.deepEqual(core.parsePlanFiles('计划改动:修复 lib/core.mjs 的解析,更新 test/x.mjs 的断言'), [])
1071
+ // 全散文 = 空数组 → rpc.mjs 既有「未解析到路径 → 重报提示」自纠正语义
1072
+ assert.deepEqual(core.planOutsideTouches(['lib/**'], core.parsePlanFiles('计划改动:已完成调研,计划只动解析器,比对逻辑不动')), [])
1073
+ // 混合清单:全角分隔散文段丢弃、正常路径行保留
1074
+ assert.deepEqual(core.parsePlanFiles('计划改动:lib/core.mjs\n修 parsePlanFiles 的解析,补全测试断言,更新 README'), ['lib/core.mjs'])
1075
+ // ② 含路径碎片的散文段(n-mvqfiofr0kt9 追加样本):说明在前、路径碎片在中 → 整条丢弃(选定语义锁死)
1076
+ assert.deepEqual(core.parsePlanFiles('计划改动:故障层复用 test/helpers/fault-provider.mjs 可变目录'), [])
1077
+ // 全角标点粘连散文(无空格)同理丢弃——截断类含全角冒号
1078
+ assert.deepEqual(core.parsePlanFiles('计划改动:故障层:复用test/helpers/fault-provider.mjs'), [])
1079
+ // 路径优先混合行:「路径+空格+说明」「路径+全角标点+说明」截到首个空白/全角标点前的路径前缀
1080
+ assert.deepEqual(core.parsePlanFiles('计划改动:test/helpers/fault-provider.mjs 故障层可变目录复用点'), ['test/helpers/fault-provider.mjs'])
1081
+ assert.deepEqual(core.parsePlanFiles('计划改动:lib/core.mjs—解析器'), ['lib/core.mjs'])
1082
+ // 截出的路径前缀参与 touches 比对:散文段不触发假越界,路径前缀正常判定
1083
+ assert.deepEqual(core.planOutsideTouches(['test/helpers/**'], core.parsePlanFiles('计划改动:test/helpers/fault-provider.mjs 故障层可变目录')), [])
1084
+ assert.deepEqual(core.planOutsideTouches(['lib/**'], core.parsePlanFiles('计划改动:lib/core.mjs 修复点,test/x.mjs 另行处理')), ['test/x.mjs'])
1085
+ // 引号包裹路径含空格不受影响(「除非引号包裹」例外分支:取首对反引号内容)
1086
+ assert.deepEqual(core.parsePlanFiles('计划改动:`my docs/a b.md`(注释)'), ['my docs/a b.md'])
1087
+ })
1088
+
1089
+ // 解析伪影收紧 + 扩权边界正向锁(task-mvtz2jjq,n-mvtejrusnhi6 生产双病案):「仲裁 expandTouches
1090
+ // 放行后重派仍被拦」的真凶不是闸口不认 extraTouches(rpc.mjs 早已读 effectiveTouches),而是
1091
+ // parsePlanFiles 的两类解析伪影被误判路径、残片永远不在边界内 → 二次拦截。
1092
+ test('parsePlanFiles: 伪影判非路径(正则残片 + 含半角斜杠的中文散文片);既有真路径零回归;effectiveTouches 正向锁', () => {
1093
+ // ① 正则/代码残片(生产实证片段):以 '/' 开头且其后含 '(' 或 '\' 转义 → 非路径
1094
+ assert.deepEqual(core.parsePlanFiles('计划改动:/if \\(isHungKillFailure(x)\\)/ 判定点'), [])
1095
+ // 哨兵证据必须在「首空白截断」前采:截断后只剩 '/if'(旧判定含 '/' 即入选 → 假越界),同行真路径仍保留
1096
+ assert.deepEqual(core.parsePlanFiles('计划改动:lib/core.mjs\n/if \\(isHungKillFailure(x)\\)/ 判定点'), ['lib/core.mjs'])
1097
+ assert.deepEqual(core.planOutsideTouches(['lib/**'], core.parsePlanFiles('计划改动:/if \\(isHungKillFailure(x)\\)/ 判定点')), [])
1098
+ // ② 含半角斜杠的中文散文片(生产实证片段):含 CJK 字符的 token 判非路径
1099
+ assert.deepEqual(core.parsePlanFiles('计划改动:调用点/接线断言仍锁'), [])
1100
+ assert.deepEqual(core.parsePlanFiles('计划改动:调用点/接线断言仍锁 —— 说明'), [])
1101
+ assert.deepEqual(core.planOutsideTouches(['lib/**'], core.parsePlanFiles('计划改动:调用点/接线断言仍锁')), [])
1102
+ // 混合清单:残片丢弃、真路径保序去重(与既有收紧断言同口径)
1103
+ assert.deepEqual(core.parsePlanFiles('计划改动:\n- 调用点/接线断言仍锁\n- lib/core.mjs\n- /if \\(x\\)/ 残片'), ['lib/core.mjs'])
1104
+ // ③ 既有真路径零回归:相对/裸扩展名/绝对/反斜杠/尾斜杠 全不受影响
1105
+ assert.deepEqual(core.parsePlanFiles('计划改动:lib/core.mjs'), ['lib/core.mjs'])
1106
+ assert.deepEqual(core.parsePlanFiles('计划改动:README.md、plan.mjs'), ['README.md', 'plan.mjs'])
1107
+ assert.deepEqual(core.parsePlanFiles('计划改动:/usr/local/lib/x.mjs'), ['/usr/local/lib/x.mjs'])
1108
+ assert.deepEqual(core.parsePlanFiles('计划改动:D:\\deepseek-work\\lib'), ['D:/deepseek-work/lib'])
1109
+ assert.deepEqual(core.parsePlanFiles('计划改动:lib/'), ['lib'])
1110
+ // ④ 正向锁:有效边界 = touches ∪ extraTouches → 扩权文件过闸(闸口全链就是这条组合:
1111
+ // rpc.planGateApply = core.effectiveTouches → core.parsePlanFiles → core.planOutsideTouches)
1112
+ var tArb = { id: 'task-arb', touches: ['src/a.mjs'], extraTouches: ['src/extra.mjs'] }
1113
+ var planArb = core.parsePlanFiles('计划改动:src/a.mjs\nsrc/extra.mjs')
1114
+ assert.deepEqual(planArb, ['src/a.mjs', 'src/extra.mjs'])
1115
+ assert.deepEqual(core.effectiveTouches(tArb), ['src/a.mjs', 'src/extra.mjs'])
1116
+ assert.deepEqual(core.planOutsideTouches(core.effectiveTouches(tArb), planArb), [], '扩权文件在有效边界内 → 零越界(扩权即边界)')
1117
+ // 反证:不并 extraTouches 时报越界——证明这条锁真在钉 effectiveTouches(否则断言恒真无意义)
1118
+ assert.deepEqual(core.planOutsideTouches(tArb.touches, planArb), ['src/extra.mjs'])
1119
+ })
1120
+
1043
1121
  test('touchCovers: 单向覆盖——touch 模式 ⊇ 计划文件(与 matchOne 对称语义方向相反)', () => {
1044
1122
  assert.equal(core.touchCovers('lib/core.mjs', 'lib/core.mjs'), true) // 精确
1045
1123
  assert.equal(core.touchCovers('lib/core.mjs', 'lib/rpc.mjs'), false)
@@ -2121,10 +2199,10 @@ test('pushRejection: 同事件判重(同时间戳/同前缀不双推)+ 空
2121
2199
 
2122
2200
  test('驳回全量带回接线(源码级):三条驳回路径都调 pushRejection + messages 通道在位', () => {
2123
2201
  const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2124
- const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2202
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
2125
2203
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
2126
2204
  assert.equal((rpc.match(/pushRejection\(t, /g) || []).length, 3) // board_verdict 工具 + verify-task RPC + task_verify 工具
2127
- assert.match(dsp, /pushRejection\(t, vsecs\.verifySummary, vsecs\.checks, t\.verification\.at, String\(rec\.id\)\)/)
2205
+ assert.match(settleSrc, /pushRejection\(t, vsecs\.verifySummary, vsecs\.checks, t\.verification\.at, String\(rec\.id\)\)/) // Verifier 文本结算随拆分第 6 刀重指 settle 源
2128
2206
  assert.equal((hostSrc().match(/pushRejection\(t, /g) || []).length, 4) // 三条路径 + Verifier 文本结算
2129
2207
  assert.match(coreSrc, /export function pushRejection/)
2130
2208
  assert.match(coreSrc, /kind: 'rejection'/)
@@ -2611,21 +2689,22 @@ test('README 双份同步记录 Token 口径(总量主显 / 近 7 天 / 有效
2611
2689
  })
2612
2690
 
2613
2691
  test('Token run 级留账:accumulateRunUsage 把本次用量写回对应 t.runs 条目(源码级断言)', () => {
2614
- const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2692
+ // 拆分第 7 刀(task-mvt8c1yx-7,仲裁扩权放行):accumulateRunUsage 随源码搬至 lib/settle.mjs——断言随源码重指 settle 源
2693
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
2615
2694
  // 五分量原样写回 runs 条目:任何维度(按天/按模型/按阶段)都能从 runs 精确重建,不必回头猜
2616
- assert.match(dsp, /if \(Array\.isArray\(t\.runs\)\)/)
2617
- assert.match(dsp, /if \(String\(t\.runs\[ri\] && t\.runs\[ri\]\.id\) === String\(rec\.id\)\)/)
2618
- assert.match(dsp, /t\.runs\[ri\]\.usage = \{ input: u\.input \|\| 0, output: u\.output \|\| 0, cacheRead: u\.cacheRead \|\| 0, cacheWrite: u\.cacheWrite \|\| 0, total: u\.total \|\| 0 \}/)
2619
- assert.match(dsp, /break/) // 命中即停(同一 runId 只记一次)
2695
+ assert.match(settleSrc, /if \(Array\.isArray\(t\.runs\)\)/)
2696
+ assert.match(settleSrc, /if \(String\(t\.runs\[ri\] && t\.runs\[ri\]\.id\) === String\(rec\.id\)\)/)
2697
+ assert.match(settleSrc, /t\.runs\[ri\]\.usage = \{ input: u\.input \|\| 0, output: u\.output \|\| 0, cacheRead: u\.cacheRead \|\| 0, cacheWrite: u\.cacheWrite \|\| 0, total: u\.total \|\| 0 \}/)
2698
+ assert.match(settleSrc, /break/) // 命中即停(同一 runId 只记一次)
2620
2699
  // 倒序查找 + 找不到就跳过:runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,正常必存在
2621
- assert.match(dsp, /for \(var ri = t\.runs\.length - 1; ri >= 0; ri--\)/)
2700
+ assert.match(settleSrc, /for \(var ri = t\.runs\.length - 1; ri >= 0; ri--\)/)
2622
2701
  // 有效消耗口径单点定义(host 侧),byDay 双指标记账与它同源
2623
- assert.match(dsp, /function effectiveOf\(u\) \{/)
2624
- assert.match(dsp, /return \(u\.input \|\| 0\) \+ \(u\.output \|\| 0\) \+ \(u\.cacheWrite \|\| 0\)/)
2625
- assert.match(dsp, /var eff = effectiveOf\(u\)/)
2702
+ assert.match(settleSrc, /function effectiveOf\(u\) \{/)
2703
+ assert.match(settleSrc, /return \(u\.input \|\| 0\) \+ \(u\.output \|\| 0\) \+ \(u\.cacheWrite \|\| 0\)/)
2704
+ assert.match(settleSrc, /var eff = effectiveOf\(u\)/)
2626
2705
  // byDay 双指标:总 t / 有效 e 各自累加;老 number 形态脏值就地收敛成对象(不把老字段形态写坏)
2627
- assert.match(dsp, /if \(!cell \|\| typeof cell !== 'object'\) cell = \{ t: Number\(cell\) \|\| 0, e: 0 \}/)
2628
- assert.match(dsp, /t\.usage\.byDay\[dk\] = cell/)
2706
+ assert.match(settleSrc, /if \(!cell \|\| typeof cell !== 'object'\) cell = \{ t: Number\(cell\) \|\| 0, e: 0 \}/)
2707
+ assert.match(settleSrc, /t\.usage\.byDay\[dk\] = cell/)
2629
2708
  })
2630
2709
 
2631
2710
  test('readRunUsage: run 不存在 / 日志目录不可读 → null(优雅降级,不抛错)', () => {
@@ -2680,9 +2759,10 @@ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表
2680
2759
  assert.match(host, /cell\.e = \(Number\(cell\.e\) \|\| 0\) \+ eff/) // 同一笔记一笔有效消耗
2681
2760
  assert.match(host, /var dk = localDayKey\(\)/) // 键取结算时刻本地日
2682
2761
  // 本地日口径:localDayKey 必须用本地 getters 拼(含 getFullYear/getMonth/getDate),不得用 toISOString
2683
- const dispatchSrc = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2684
- const dkBody = dispatchSrc.match(/function localDayKey\(d\) \{[\s\S]{0,300}?\n \}/)
2685
- assert.ok(dkBody, 'dispatch.mjs 应定义 localDayKey')
2762
+ // (拆分第 7 刀 task-mvt8c1yx-7:localDayKey 随 accumulateRunUsage 搬至 lib/settle.mjs,断言随源码重指)
2763
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
2764
+ const dkBody = settleSrc.match(/function localDayKey\(d\) \{[\s\S]{0,300}?\n \}/)
2765
+ assert.ok(dkBody, 'settle.mjs 应定义 localDayKey')
2686
2766
  assert.match(dkBody[0], /getFullYear\(\)/)
2687
2767
  assert.match(dkBody[0], /getMonth\(\) \+ 1/)
2688
2768
  assert.match(dkBody[0], /getDate\(\)/)
@@ -3614,8 +3694,13 @@ const flush = () => new Promise(r => setImmediate(r))
3614
3694
  // 子进程探针源码(可续跑 Worker⑤ 用):只在子进程里执行「真跑一次 continuable 派发 + 真读会话日志 + 真结算」。
3615
3695
  // 为什么必须子进程:findRunLog 走 os.homedir()(进程内首次调用即缓存),临时 HOME 只有在进程启动前
3616
3696
  // 设好才生效——同进程改 env 既无效又会带偏其它用例与真实看板目录。
3697
+ // CWD 解耦(task-mvr44r4m):探针源码是字符串模板,其相对导入按子进程 CWD 解析——从仓库根跑会 ERR_MODULE_NOT_FOUND。
3698
+ // 修法:在本测试文件用 import.meta.url 换算出包内 lib 的 file:// 绝对 URL(Windows 下 ESM import 拒收裸盘符路径),
3699
+ // 注入探针源码头部的占位符,子进程 env/CWD 怎么变都不受影响。
3700
+ const PROBE_LIB_DISPATCH = new URL('../lib/dispatch.mjs', import.meta.url).href
3701
+ const PROBE_REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)).replace(/[\\/]+$/, '') // git 仓库根(包外三层),钉 cwd 锁死回归
3617
3702
  const PROBE_CONTINUABLE_SOURCE = `
3618
- const { createDispatch } = await import('./lib/dispatch.mjs')
3703
+ const { createDispatch } = await import(${JSON.stringify(PROBE_LIB_DISPATCH)})
3619
3704
  const SID = 'session-test-0000-0000-000000000000'
3620
3705
  const listeners = []
3621
3706
  const runs = {}
@@ -3667,8 +3752,11 @@ test('可续跑 Worker①:worker 走 startContinuable(rec 持 childId),v
3667
3752
  // verifier 的 rec 仍是旧形态(有 run、无 continuable)
3668
3753
  assert.equal(h.runs['v1'].continuable, undefined)
3669
3754
  assert.equal(String(h.runs['v1'].id), 'run-1')
3670
- // 订阅已注册且事件名正确(回退开关不影响的公共通道)
3671
- assert.deepEqual(h.listeners.map(l => l.name), ['agent/status'])
3755
+ // 订阅已注册且事件名正确(回退开关不影响的公共通道);挂死检测 v2 起帧活性订阅一并建立——
3756
+ // 两种订阅都在位即可,顺序不断言死(防未来再加订阅又炸)
3757
+ const names = h.listeners.map(l => l.name)
3758
+ assert.ok(names.includes('agent/status'), 'agent/status 订阅在位')
3759
+ assert.ok(names.includes('agent/assistant-stream'), 'agent/assistant-stream 订阅在位(帧活性)')
3672
3760
  })
3673
3761
 
3674
3762
  test('可续跑 Worker②:agent/status running→idle → settle 推进任务(真跑事件回调,非源码断言)', async () => {
@@ -3778,7 +3866,8 @@ test('可续跑 Worker⑤:idle 后从子会话 v4 日志读到助手文本 →
3778
3866
  fs.mkdirSync(path.join(tmp, '.dsh', 'sessions', 'b1', 'child-ok'), { recursive: true })
3779
3867
  fs.writeFileSync(path.join(tmp, '.dsh', 'sessions', 'b1', 'child-ok', 'session.v4.jsonl.zstd'), Buffer.concat(chunks))
3780
3868
  try {
3781
- const child = spawnSync(process.execPath, ['-e', PROBE_CONTINUABLE_SOURCE], { env: Object.assign({}, process.env, { HOME: tmp, USERPROFILE: tmp }), encoding: 'utf8' })
3869
+ // cwd 显式钉到仓库根(PROBE_REPO_ROOT):锁死「仓库根 CWD 跑探针」回归——import 已绝对锚定,CWD 不再影响解析。
3870
+ const child = spawnSync(process.execPath, ['-e', PROBE_CONTINUABLE_SOURCE], { cwd: PROBE_REPO_ROOT, env: Object.assign({}, process.env, { HOME: tmp, USERPROFILE: tmp }), encoding: 'utf8' })
3782
3871
  assert.equal(child.status, 0, 'probe 失败: ' + child.stderr)
3783
3872
  const out = JSON.parse(child.stdout.trim().split('\n').pop())
3784
3873
  assert.equal(out.status, 'verifying') // 文本通道:有助手文本 → 正常推进 verifying
@@ -3790,28 +3879,29 @@ test('可续跑 Worker⑤:idle 后从子会话 v4 日志读到助手文本 →
3790
3879
 
3791
3880
  test('可续跑 Worker 接线(源码级):事件订阅走 ctx.effect 回收 + 硬超时 interrupt 留存 + 重派续跑接线', () => {
3792
3881
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
3882
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
3793
3883
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
3794
3884
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
3795
3885
  // ① 双路 spawn:Worker+开关开才走 startContinuable(verifier/hook 不受影响)
3796
3886
  assert.match(dsp, /var useContinuable = role === 'worker' && c\.workerContinuable !== false/)
3797
3887
  assert.match(dsp, /var cs = await subagents\.startContinuable\(\{ provider: providerName, label: req\.label, request: req, signal: req\.signal \}\)/)
3798
3888
  // ② 结算观测:agent/status 订阅 + running→idle + rec.ran 二次守卫 + effect 回收
3799
- assert.match(dsp, /ctx\.effect\(function \(\) \{ return ctx\.on\('agent\/status', onAgentStatus\) \}\)/)
3800
- assert.match(dsp, /if \(status !== 'idle' && status !== 'running'\) return/) // running 事件必须放行(rec.ran 的唯一来源)
3801
- assert.match(dsp, /if \(status === 'running'\) \{ rec\.ran = true; return \}/)
3802
- assert.match(dsp, /if \(!rec\.ran \|\| rec\.settled\) return/)
3803
- assert.match(dsp, /await settleRun\(sid, rec, \{ output: \[\{ type: 'text', text: text \}\], stopReason: text\.trim\(\) \? 'completed' : 'error' \}, null\)/)
3889
+ assert.match(dsp, /ctx\.effect\(function \(\) \{ return ctx\.on\('agent\/status', settle\.onAgentStatus\) \}\)/)
3890
+ assert.match(settleSrc, /if \(status !== 'idle' && status !== 'running'\) return/) // running 事件必须放行(rec.ran 的唯一来源)
3891
+ assert.match(settleSrc, /if \(status === 'running'\) \{ rec\.ran = true; return \}/)
3892
+ assert.match(settleSrc, /if \(!rec\.ran \|\| rec\.settled\) return/)
3893
+ assert.match(settleSrc, /await settleRun\(sid, rec, \{ output: \[\{ type: 'text', text: text \}\], stopReason: text\.trim\(\) \? 'completed' : 'error' \}, null\)/)
3804
3894
  // ③ 卡2 Step1:硬超时/失败结算对 continuable 子会话改 interrupt(留存不销毁),一次性仍 dispose;
3805
3895
  // authority 形状取 dsh-subagent 的 SubagentInterruptAuthority,interrupt 异常被吞进 resumeBlocked 不阻断结算。
3806
- assert.match(dsp, /async function endContinuable\(sid, rec\) \{/)
3807
- assert.match(dsp, /subagents\.interrupt\(rec\.childId, \{ kind: 'ancestor', agent: parent \}\)/)
3808
- assert.match(dsp, /if \(rec\.continuable\) await endContinuable\(sid, rec\)\s*\n\s*else await rec\.run\.dispose\(\)/)
3809
- assert.match(dsp, /rec\.resumeBlocked = true/)
3896
+ assert.match(settleSrc, /async function endContinuable\(sid, rec\) \{/)
3897
+ assert.match(settleSrc, /subagents\.interrupt\(rec\.childId, \{ kind: 'ancestor', agent: parent \}\)/)
3898
+ assert.match(settleSrc, /if \(rec\.continuable\) await endContinuable\(sid, rec\)\s*\n\s*else await rec\.run\.dispose\(\)/)
3899
+ assert.match(settleSrc, /rec\.resumeBlocked = true/)
3810
3900
  assert.match(dsp, /withTimeout\(abortableP, hardMs, rec\.role \+ ':' \+ t\.id\)/)
3811
3901
  // ④ 文本兜底复用 usage.mjs 现成读取器(与 agent-activity 同一套日志定位/分帧)+ 续跑基线(只认本轮新写字节)
3812
- assert.match(dsp, /var log = findRunLog\(childId, sessionsRoot\)/)
3813
- assert.match(dsp, /var buf = readLogBytes\(log, tail\)/)
3814
- assert.match(dsp, /childSessionOutput\(rec\.childId, rec\.baselineBytes\)/)
3902
+ assert.match(settleSrc, /var log = findRunLog\(childId, sessionsRoot\)/)
3903
+ assert.match(settleSrc, /var buf = readLogBytes\(log, tail\)/)
3904
+ assert.match(settleSrc, /childSessionOutput\(rec\.childId, rec\.baselineBytes\)/)
3815
3905
  assert.match(dsp, /baselineBytes: logSizeOf\(childId\)/)
3816
3906
  // ⑤ 卡2 Step2/Step3:重派续跑接线(sendMessage 冷复活 + 薄框架断点续跑指令 + runs[] 续跑记录 +
3817
3907
  // 失败回退 fresh spawn)+ 三连败计数不受续跑影响(续跑同样走 settleWorker 的 retryCount 口径)
@@ -4037,11 +4127,12 @@ test('可续跑 Worker⑪(卡2⑤):workerContinuable=false → 续跑机
4037
4127
 
4038
4128
  // 子进程探针(卡2 续跑基线护栏):真建 v4 日志 → 真续跑 → 区分「续跑轮没产出(旧文本不得冒充交付物)」
4039
4129
  // 与「续跑轮真产出(正常推进 verifying)」。必须子进程:findRunLog 走 os.homedir() 且进程内首次调用即缓存。
4130
+ // CWD 解耦(task-mvr44r4m):相对导入改为头部注入的 PROBE_LIB_DISPATCH file:// 绝对 URL(import.meta.url 换算),与 CWD 无关。
4040
4131
  const PROBE_RESUME_BASELINE_SOURCE = `
4041
4132
  const fs = await import('node:fs')
4042
4133
  const path = await import('node:path')
4043
4134
  const zlib = await import('node:zlib')
4044
- const { createDispatch } = await import('./lib/dispatch.mjs')
4135
+ const { createDispatch } = await import(${JSON.stringify(PROBE_LIB_DISPATCH)})
4045
4136
  const SID = 'session-test-0000-0000-000000000000'
4046
4137
  const childId = 'child-old'
4047
4138
  const logDir = path.join(process.env.HOME, '.dsh', 'sessions', 'b1', childId)
@@ -4089,7 +4180,8 @@ console.log(JSON.stringify({ stale: stale, ok: { status: t.status, deliverable:
4089
4180
  test('可续跑 Worker⑫(卡2 续跑基线):续跑轮没产出 → 残留旧文本不得冒充交付物(按失败重排);真产出 → 正常推进 verifying', async () => {
4090
4181
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tbc2-'))
4091
4182
  try {
4092
- const child = spawnSync(process.execPath, ['-e', PROBE_RESUME_BASELINE_SOURCE], { env: Object.assign({}, process.env, { HOME: tmp, USERPROFILE: tmp }), encoding: 'utf8' })
4183
+ // cwd 显式钉到仓库根(PROBE_REPO_ROOT):锁死「仓库根 CWD 跑探针」回归——import 已绝对锚定,CWD 不再影响解析。
4184
+ const child = spawnSync(process.execPath, ['-e', PROBE_RESUME_BASELINE_SOURCE], { cwd: PROBE_REPO_ROOT, env: Object.assign({}, process.env, { HOME: tmp, USERPROFILE: tmp }), encoding: 'utf8' })
4093
4185
  assert.equal(child.status, 0, 'probe 失败: ' + child.stderr)
4094
4186
  const out = JSON.parse(child.stdout.trim().split('\n').pop())
4095
4187
  assert.deepEqual(out.sent, ['child-old', 'child-old']) // 两轮都是真续跑(sendMessage 冷复活)
@@ -4130,12 +4222,13 @@ test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run →
4130
4222
 
4131
4223
  test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
4132
4224
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
4225
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
4133
4226
  // spawnOneShot:hook 角色走 buildHookPrompt(phase 由 role 决定)
4134
4227
  assert.match(dsp, /if \(role === 'hook-pre' \|\| role === 'hook-post'\) \{/)
4135
4228
  assert.match(dsp, /buildHookPrompt\(t, role === 'hook-pre' \? 'pre' : 'post', kids\)/)
4136
- // settleRun:hook 分支进 settleHook(照 worker/verifier 结算模式)
4137
- assert.match(dsp, /else if \(rec\.role === 'hook-pre' \|\| rec\.role === 'hook-post'\) await settleHook\(sid, rec, output, failed, errText\)/)
4138
- assert.match(dsp, /applyHookSettle\(d, rec\.taskId, phase, !\(failed \|\| esc\), output, runId, errText\)/)
4229
+ // settleRun:hook 分支模块内直调 settleHook(拆分第 6 刀撤 roles 懒注入;照 worker/verifier 结算模式)
4230
+ assert.match(settleSrc, /else if \(rec\.role === 'hook-pre' \|\| rec\.role === 'hook-post'\) await settleHook\(sid, rec, output, failed, errText\)/)
4231
+ assert.match(settleSrc, /applyHookSettle\(d, rec\.taskId, phase, !\(failed \|\| esc\), output, runId, errText\)/) // 持锁段随源码重指 settle 源
4139
4232
  // pre 闸门占用(state=running 即幂等占用)与 post 补 spawn(pending 标记驱动,复用 verifierRun 幂等位)
4140
4233
  assert.match(dsp, /if \(hookOn\(t, 'pre'\) && t\.hooks\.pre\.state === 'idle' && !runs\[t\.id\]\) \{/)
4141
4234
  assert.match(dsp, /hookSetState\(t, 'pre', 'running', 'system', '派发前置 hook run'\)/)
@@ -4180,11 +4273,12 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
4180
4273
  // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、
4181
4274
  // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)、
4182
4275
  // 自测指南归一(normalizeUserTest,task-muxyyvg0)扩展;
4183
- // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)、
4276
+ // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)、
4184
4277
  // depsSatisfied(task-mv9yupm4,spawn 落闸锁内补验依赖)、
4185
4278
  // Verifier 验收员加餐常量(VERIFIER_PERSONA/VERIFIER_TOOL_FILTER,task-muy3gm03)、
4186
- // 人工/外部操作留痕单行摘要(opsLogLine,task-mvnf3gly,Team 注入段「最近人工/外部操作」小节)
4187
- assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER, opsLogLine \} = core/)
4279
+ // 人工/外部操作留痕单行摘要(opsLogLine,task-mvnf3gly,Team 注入段「最近人工/外部操作」小节);
4280
+ // 拆分第 6 刀(task-mvt8c1yx-6):applyHookSettle/pushRejection 随角色结算搬至 settle.mjs 解构(本表不再持有)
4281
+ assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, hookOn, hookSetState, gsb, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER, opsLogLine \} = core/)
4188
4282
  // rpc.mjs 解构表尾部再随建卡派发预警(appendDispatchWarning,task-mvbealtf)、力度路由档位表(TASK_SIZES,task-mvnemvf5)、
4189
4283
  // 人工/外部操作留痕单行摘要(opsLogLine,task-mvnf3gly,task_list recentOps 透出)扩展
4190
4284
  assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint, appendDispatchWarning, TASK_SIZES, opsLogLine \} = core/)
@@ -4200,7 +4294,7 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
4200
4294
  // 函数声明提升后被头部 var x = deps.x 赋值覆盖,是永不执行的死代码——改错地方不产生效果,挪赋值顺序即引爆)。
4201
4295
  // 此处断言六个函数的定义只存在于 notify.mjs,任何模块再长出副本即红。
4202
4296
  test('notify 单一来源:notifyTaskDone/flushReceipts/deliverEscalation 等函数定义只存在于 lib/notify.mjs', () => {
4203
- const names = ['deliverEscalation', 'notifyMainWindow', 'maybeNotify', 'notifyTaskDone', 'notifyDispatched', 'pushSysNote', 'flushReceipts']
4297
+ const names = ['deliverEscalation', 'notifyMainWindow', 'maybeNotify', 'notifyTaskDone', 'notifyDispatched', 'notifyDecomposed', 'pushSysNote', 'flushReceipts']
4204
4298
  const others = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
4205
4299
  const notifySrc = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
4206
4300
  for (const name of names) {
@@ -4300,6 +4394,167 @@ test('派发回执④:deps 未注入 notifyDispatched(老 host)→ 静默
4300
4394
  assert.equal(h.state.receiptBuf.s1.items.length, 1)
4301
4395
  })
4302
4396
 
4397
+ // ===== 拆解官回执(task-mvrqiu6x):notifyDecomposed 入 45s 聚合队列「🧩 已拆解」分区 =====
4398
+ test('拆解回执①:notifyDecomposed 入队(kind=decomposed)→ flush 出「🧩 已拆解」分区(split/keep 文案)', async () => {
4399
+ const h = mkNotify({ board: { tasks: [mkTask({ id: 'src1', title: '需求卡', status: 'draft' })] } })
4400
+ h.notify.notifyDecomposed('s1', { sourceId: 'src1', sourceTitle: '需求卡', verdict: 'split', children: [{ id: 'src1-1', title: '子卡A' }, { id: 'src1-2', title: '子卡B' }], reason: '' })
4401
+ const buf = h.state.receiptBuf.s1
4402
+ assert.equal(buf.items.length, 1)
4403
+ assert.equal(buf.items[0].kind, 'decomposed')
4404
+ assert.equal(buf.items[0].id, 'src1')
4405
+ pushFour(h)
4406
+ await new Promise(r => setImmediate(r))
4407
+ assert.equal(h.captured.length, 1)
4408
+ const txt = textOf(h.captured[0])
4409
+ assert.match(txt, /🧩 已拆解 1 个(待确认发布):/)
4410
+ assert.match(txt, /需求卡 \(src1\) — split:2 张子卡(子卡A、子卡B)/)
4411
+ assert.equal(h.captured[0].source.kind, 'plugin:dsh-agent-board')
4412
+ // keep 判定:同分区「无需拆分」文案(断言③)
4413
+ const h2 = mkNotify({ board: { tasks: [mkTask({ id: 'src2', title: '无需拆卡', status: 'draft' })] } })
4414
+ h2.notify.notifyDecomposed('s1', { sourceId: 'src2', sourceTitle: '无需拆卡', verdict: 'keep', children: [], reason: '结构单一' })
4415
+ pushFour(h2)
4416
+ await new Promise(r => setImmediate(r))
4417
+ const txt2 = textOf(h2.captured[0])
4418
+ assert.match(txt2, /🧩 已拆解 1 个(待确认发布):/)
4419
+ assert.match(txt2, /无需拆卡 \(src2\) — keep:无需拆分/)
4420
+ })
4421
+
4422
+ test('拆解回执②:源卡停 draft/pending(非 in-progress)→ decomposed 回执不被投递过滤器丢弃(关键回归)', async () => {
4423
+ // 源卡 draft/pending 是设计常态——正是原实现绕行系统通道借 pushSysNote 的原因;
4424
+ // 此处把 deliver 投递过滤器的「decomposed 豁免」钉死,防后人把「非 in-progress 丢弃」改成通用规则误伤
4425
+ for (const st of ['draft', 'pending']) {
4426
+ const h = mkNotify({ board: { tasks: [mkTask({ id: 'src9', title: '需求卡', status: st })] } })
4427
+ h.notify.notifyDecomposed('s1', { sourceId: 'src9', sourceTitle: '需求卡', verdict: 'split', children: [{ id: 'src9-1', title: '子卡X' }], reason: '' })
4428
+ pushFour(h)
4429
+ await new Promise(r => setImmediate(r))
4430
+ assert.equal(h.captured.length, 1, st + ' 状态消息应投递')
4431
+ const txt = textOf(h.captured[0])
4432
+ assert.match(txt, /🧩 已拆解 1 个(待确认发布):/, st + ' 拆解回执不得被「任务非 in-progress」过滤器丢弃')
4433
+ assert.match(txt, /需求卡 \(src9\) — split:1 张子卡(子卡X)/)
4434
+ }
4435
+ })
4436
+
4437
+ test('拆解回执⑥:源卡已 archived → decomposed 回执被丢弃(人已手动归档=已知悉)', async () => {
4438
+ const h = mkNotify({ board: { tasks: [mkTask({ id: 'srcA', title: '已归档卡', status: 'archived' })] } })
4439
+ h.notify.notifyDecomposed('s1', { sourceId: 'srcA', sourceTitle: '已归档卡', verdict: 'split', children: [{ id: 'srcA-1', title: '子卡Y' }], reason: '' })
4440
+ assert.equal(h.state.receiptBuf.s1.items.length, 1) // 入队照常,flush 时投递前过滤
4441
+ pushFour(h)
4442
+ await new Promise(r => setImmediate(r))
4443
+ assert.equal(h.captured.length, 1)
4444
+ const txt = textOf(h.captured[0])
4445
+ assert.doesNotMatch(txt, /🧩 已拆解/) // 源卡已 archived → 拆解回执被吞(唯一离场规则)
4446
+ assert.match(txt, /✅ 完成 4 个:/) // 完成回执(主通道)照常投递
4447
+ })
4448
+
4449
+ test('拆解回执⑦:同一批重复上报 → 幂等零重复;换一批子卡/verdict → 指纹不同正常新回执', () => {
4450
+ const h = mkNotify({ board: { tasks: [] } })
4451
+ const payload = { sourceId: 'srcI', sourceTitle: '幂等卡', verdict: 'split', children: [{ id: 'srcI-1', title: 'A' }, { id: 'srcI-2', title: 'B' }] }
4452
+ h.notify.notifyDecomposed('s1', payload)
4453
+ h.notify.notifyDecomposed('s1', payload) // 同一批提案重复上报 → 幂等键(源卡id|verdict|子卡id集)一致 → 吞掉
4454
+ assert.equal(h.state.receiptBuf.s1.items.length, 1)
4455
+ h.notify.notifyDecomposed('s1', Object.assign({}, payload, { children: [{ id: 'srcI-3', title: 'C' }] })) // 换一批子卡
4456
+ assert.equal(h.state.receiptBuf.s1.items.length, 2)
4457
+ h.notify.notifyDecomposed('s1', { sourceId: 'srcI', sourceTitle: '幂等卡', verdict: 'keep', children: [] }) // verdict 变 → 新指纹
4458
+ assert.equal(h.state.receiptBuf.s1.items.length, 3)
4459
+ })
4460
+
4461
+ // 拆解官结算分支 harness(task-mvrqiu6x):decomposer 一次性 run 以给定结果落定 → settleDecompose 三分支。
4462
+ // 与 runVerifierFuseCase 同构:createDispatch 直调 + mock subagents.start(run.result 由用例给定)+
4463
+ // pushSysNote/notifyDecomposed 捕获;源卡 draft(设计常态),spawnOneShot 直调(拆解官由 task_decompose 派生)。
4464
+ async function runDecomposeCase(opts) {
4465
+ const o = opts || {}
4466
+ const t = mkTask({ id: 'dec-src', title: '待拆解源卡', status: 'draft' })
4467
+ const board = mkBoard([t])
4468
+ if (o.notifyDone === false) board.notifyDone = false
4469
+ const notes = []
4470
+ const deco = []
4471
+ const runs = {}
4472
+ const listeners = []
4473
+ const ctx = {
4474
+ fs: {}, get: function () { return null }, timer: null,
4475
+ effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
4476
+ on: function (name, fn) { listeners.push({ name: name, fn: fn }); return function () {} },
4477
+ subagents: {
4478
+ list: () => ['mock'],
4479
+ getProvider: () => ({ inheritsParentContext: false }),
4480
+ // spawn 本身成功;run.result 由用例给定(structured 分支 / 纯文本 / 失败)
4481
+ start: async () => ({ id: 'run-dec', result: o.result, dispose: async function () {} }),
4482
+ startContinuable: async () => ({ childId: 'child-x', messageId: 'm1' }),
4483
+ },
4484
+ }
4485
+ const dispatch = createDispatch(ctx, { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: { [FULL_SID]: runs } }, {
4486
+ rt: async () => board, wt: async () => {}, mutateLocked: async (sid, fn) => fn(board), kickCycle: () => {},
4487
+ rootForSession: () => ({ id: FULL_SID }),
4488
+ withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
4489
+ pushSysNote: (sid, text) => { notes.push(text) }, maybeNotify: () => {}, notifyTaskDone: () => {},
4490
+ notifyDecomposed: (sid, payload) => { deco.push(payload) },
4491
+ })
4492
+ const rec = await dispatch.spawnOneShot(FULL_SID, board.tasks[0], 'decomposer')
4493
+ assert.ok(rec, 'decomposer spawn 应成功(源卡 draft)')
4494
+ const flushN = () => new Promise(r => setImmediate(r))
4495
+ for (var i = 0; i < 8; i++) await flushN()
4496
+ return { notes: notes, deco: deco, board: board }
4497
+ }
4498
+
4499
+ test('拆解回执④split:settleDecompose split 成功 → notifyDecomposed 正式回执(不再伪装 ⚠️ 系统异常)', async () => {
4500
+ const r = await runDecomposeCase({ result: Promise.resolve({ output: [{ type: 'text', text: '' }], stopReason: 'completed', structured: { verdict: 'split', children: [{ title: '子卡A', size: 'light', touches: [], acceptance: 'x' }] } }) })
4501
+ assert.equal(r.deco.length, 1, 'split 成功应发拆解回执')
4502
+ assert.equal(r.deco[0].sourceId, 'dec-src')
4503
+ assert.equal(r.deco[0].sourceTitle, '待拆解源卡')
4504
+ assert.equal(r.deco[0].verdict, 'split')
4505
+ assert.equal(r.deco[0].children.length, 1)
4506
+ assert.equal(r.deco[0].children[0].id, 'dec-src-1') // decomposeChildId:parentId-N
4507
+ assert.equal(r.notes.length, 0, '成功信号不得再借 pushSysNote 伪装成 ⚠️ 系统异常')
4508
+ const t = r.board.tasks.find(x => x.id === 'dec-src')
4509
+ assert.equal(t.status, 'draft', '源卡状态不变')
4510
+ assert.ok(t.messages.some(m => /🧩 拆解提案已就位/.test(m.text)), '源卡 messages 留痕照旧')
4511
+ })
4512
+
4513
+ test('拆解回执③keep:settleDecompose keep → 同分区「无需拆分」回执(不落系统异常)', async () => {
4514
+ const r = await runDecomposeCase({ result: Promise.resolve({ output: [{ type: 'text', text: '' }], stopReason: 'completed', structured: { verdict: 'keep', rationale: '结构单一' } }) })
4515
+ assert.equal(r.deco.length, 1, 'keep 应发拆解回执')
4516
+ assert.equal(r.deco[0].sourceId, 'dec-src')
4517
+ assert.equal(r.deco[0].verdict, 'keep')
4518
+ assert.equal(r.deco[0].reason, '结构单一')
4519
+ assert.equal(r.notes.length, 0, 'keep 成功也不落 ⚠️ 系统异常')
4520
+ const t2 = r.board.tasks.find(x => x.id === 'dec-src')
4521
+ assert.ok(t2.messages.some(m => /🧩 拆解官判定无需拆分/.test(m.text)), 'rationale 落源卡消息照旧')
4522
+ })
4523
+
4524
+ test('拆解回执④失败:run 失败/structured 缺失 → 仍落「⚠️ 系统异常」区且不产生 decomposed 回执', async () => {
4525
+ const r1 = await runDecomposeCase({ result: Promise.reject(new Error('provider timeout')) })
4526
+ assert.equal(r1.deco.length, 0, 'run 失败不产生拆解回执')
4527
+ assert.equal(r1.notes.length, 1)
4528
+ assert.match(r1.notes[0], /🚨 拆解官 run 未产出可用提案(源卡 dec-src)/, '失败分支保持 🚨 系统通道逐字不变')
4529
+ const t1 = r1.board.tasks.find(x => x.id === 'dec-src')
4530
+ assert.ok(t1.messages.some(m => /🧩 拆解官 run 失败/.test(m.text)), '失败原因落源卡消息照旧')
4531
+
4532
+ const r2 = await runDecomposeCase({ result: Promise.resolve({ output: [{ type: 'text', text: '自由文本' }], stopReason: 'completed' }) })
4533
+ assert.equal(r2.deco.length, 0, 'structured 缺失不产生拆解回执')
4534
+ assert.equal(r2.notes.length, 1)
4535
+ assert.match(r2.notes[0], /🚨 拆解官 run 未产出可用提案/, 'structured 缺失走同一 🚨 失败臂')
4536
+ })
4537
+
4538
+ test('拆解回执⑤:notifyDone=false → 拆解回执不入队;源卡 messages 留痕照旧不丢', async () => {
4539
+ const r = await runDecomposeCase({ notifyDone: false, result: Promise.resolve({ output: [{ type: 'text', text: '' }], stopReason: 'completed', structured: { verdict: 'split', children: [{ title: '子卡A', size: 'light', touches: [], acceptance: 'x' }] } }) })
4540
+ assert.equal(r.deco.length, 0, '完成回执开关关 → 拆解回执不入队')
4541
+ assert.equal(r.notes.length, 0, '开关只闸回执,成功路径也不再落系统异常')
4542
+ const t3 = r.board.tasks.find(x => x.id === 'dec-src')
4543
+ assert.ok(t3.messages.some(m => /🧩 拆解提案已就位/.test(m.text)), '关开关时源卡 messages 留痕照旧不丢')
4544
+ const r2 = await runDecomposeCase({ notifyDone: false, result: Promise.reject(new Error('boom')) })
4545
+ assert.equal(r2.notes.length, 1, '失败分支不受 notifyDone 影响(异常就该是异常)')
4546
+ })
4547
+
4548
+ test('拆解回执⑧:settleDecompose 三分支与 index 接线在位(源面断言,防接线回退)', () => {
4549
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8') // 拆分第 6 刀:settleDecompose 随源码重指 settle 源
4550
+ // split/keep 成功走 notifyDecomposed(notifyDone 闸门 + 老 host 未注入静默),失败分支保持 🚨 pushSysNote
4551
+ assert.match(settleSrc, /if \(doneOn && typeof notifyDecomposed === 'function'\) notifyDecomposed\(sid, \{ sourceId: rec\.taskId, sourceTitle: srcTitle, verdict: 'split'/)
4552
+ assert.match(settleSrc, /if \(doneOn && typeof notifyDecomposed === 'function'\) notifyDecomposed\(sid, \{ sourceId: rec\.taskId, sourceTitle: srcTitle, verdict: 'keep'/)
4553
+ assert.match(settleSrc, /else pushSysNote\(sid, '🚨 拆解官 run 未产出可用提案(源卡 ' \+ rec\.taskId \+ '):'/)
4554
+ const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
4555
+ assert.match(idx, /notifyDecomposed: notify\.notifyDecomposed/) // index.mjs 接线在位
4556
+ })
4557
+
4303
4558
  // ===== 回执开关进设置(task-muuwgcro):板级 notifyDispatch/notifyDone 双布尔(缺省 true)+ 两处闸门 =====
4304
4559
  // 用户指令:「回执可以做一个开关,放到设置里」。语义:设置区「通知」小节可分别关掉「派发回执」与
4305
4560
  // 「完成回执」;歧义裁决通知(notifyMainWindow)是裁决通道不是回执,不接入开关。
@@ -4388,16 +4643,17 @@ test('回执开关③b:notifyDone=false → worker 三连败转 blocked 也不
4388
4643
  })
4389
4644
 
4390
4645
  test('回执开关③/④:完成/阻塞回执闸门在结算调用方;歧义裁决通道零开关(源码级断言)', () => {
4391
- const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
4646
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8') // 拆分第 6 刀:四个结算函数随源码重指 settle 源
4392
4647
  const noti = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
4393
- // 完成回执:三个结算函数(worker/verifier/hook)都在同一次持锁回调里取 notifyDone(零额外读盘)
4394
- assert.equal((dsp.match(/doneOn = cfg\(d\)\.notifyDone !== false/g) || []).length, 3)
4395
- assert.equal((dsp.match(/if \(doneOn && /g) || []).length, 5) // worker 2 + verifier 2 + hook-closed 1
4396
- assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'resolved'\) notifyTaskDone\(sid, result\.task, 'resolved'\)/)
4397
- assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'blocked'\) notifyTaskDone\(sid, result\.task, 'blocked'\)/)
4398
- assert.match(dsp, /if \(result\.blocked\) \{ maybeNotify\(sid, result\.task\); if \(doneOn\) notifyTaskDone\(sid, result\.task, 'blocked'\) \}/)
4648
+ // 完成回执:四个结算函数(worker/verifier/hook + 拆解官 decompose,task-mvrqiu6x 起后者也受 notifyDone 约束)
4649
+ // 都在同一次持锁回调里取 notifyDone(零额外读盘)
4650
+ assert.equal((settleSrc.match(/doneOn = cfg\(d\)\.notifyDone !== false/g) || []).length, 4)
4651
+ assert.equal((settleSrc.match(/if \(doneOn && /g) || []).length, 7) // worker 2 + verifier 2 + hook-closed 1 + decompose split/keep 2
4652
+ assert.match(settleSrc, /if \(doneOn && result\.task && result\.task\.status === 'resolved'\) notifyTaskDone\(sid, result\.task, 'resolved'\)/)
4653
+ assert.match(settleSrc, /if \(doneOn && result\.task && result\.task\.status === 'blocked'\) notifyTaskDone\(sid, result\.task, 'blocked'\)/)
4654
+ assert.match(settleSrc, /if \(result\.blocked\) \{ maybeNotify\(sid, result\.task\); if \(doneOn\) notifyTaskDone\(sid, result\.task, 'blocked'\) \}/)
4399
4655
  // 闸门默认 true:回调未跑到也不漏报(宁可多报)
4400
- assert.equal((dsp.match(/var doneOn = true/g) || []).length, 3)
4656
+ assert.equal((settleSrc.match(/var doneOn = true/g) || []).length, 4) // worker/verifier/hook 各一 + settleDecompose 一
4401
4657
  // ④ 歧义通道零开关:通知层根本读不到板级配置(cfg 未引入),去注释后的上报代码段不含两个开关名
4402
4658
  assert.doesNotMatch(noti, /cfg\(/)
4403
4659
  const escBlock = noti.slice(noti.indexOf('// 歧义上报通知'), noti.indexOf('// ===== 任务回执通知'))
@@ -4405,7 +4661,7 @@ test('回执开关③/④:完成/阻塞回执闸门在结算调用方;歧义
4405
4661
  const escCode = escBlock.split('\n').filter(l => !/^\s*\/\//.test(l)).join('\n')
4406
4662
  assert.doesNotMatch(escCode, /notifyDispatch|notifyDone/)
4407
4663
  // 三个结算点的歧义通知调用(maybeNotify)都不带任何开关闸门
4408
- assert.equal((dsp.match(/maybeNotify\(sid, result\.task\)/g) || []).length, 3)
4664
+ assert.equal((settleSrc.match(/maybeNotify\(sid, result\.task\)/g) || []).length, 3)
4409
4665
  })
4410
4666
 
4411
4667
  test('回执开关⑤:设置弹层「通知」小节两行开关 + set-board-config 读写通道(源码 + RPC 行为)', async () => {
@@ -5663,22 +5919,28 @@ test('僵尸 run 留档清扫:落定卡 running 无 endedAt 留档被关账+
5663
5919
 
5664
5920
  test('僵尸 run 留档清扫接线(源码级):sweepZombieRuns 定义 + poolCycle 挂点 + 只清扫落定卡门禁', () => {
5665
5921
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5922
+ // 拆分第 7 刀(task-mvt8c1yx-7,仲裁扩权放行):sweepZombieRuns 随源码搬至 lib/settle.mjs
5923
+ // ——函数体机制锁断言随源码重指 settle 源;poolCycle 挂点接线断言仍锁 dispatch 源(挂点改 settle.* 形态)。
5924
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
5666
5925
  // ① 清扫函数定义(含「只对落定卡做」状态门禁与「无活跃 rec 才扫」护栏)
5667
- assert.match(dsp, /async function sweepZombieRuns\(sid, snap\) \{/)
5668
- assert.match(dsp, /if \(t\.status !== 'resolved' && t\.status !== 'cancelled' && t\.status !== 'archived'\) return/)
5669
- assert.match(dsp, /if \(rec && !rec\.settled && String\(rec\.id\) === rid\) return/)
5670
- assert.match(dsp, /r\.outcome = 'stale-closed'/)
5671
- assert.match(dsp, /r\.endedAt = new Date\(\)\.toISOString\(\)/)
5672
- assert.match(dsp, /r\.note = 'host 重启僵尸留档清扫'/)
5673
- assert.match(dsp, /if \(t\.verifierRun === rid\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
5674
- assert.match(dsp, /if \(t\.claimedBy === rid\) \{ t\.claimedBy = null; t\.claimedAt = null \}/)
5675
- assert.match(dsp, /return any \? d : null/) // 无改动不写盘(安静板不因清扫产生心跳写盘)
5926
+ assert.match(settleSrc, /async function sweepZombieRuns\(sid, snap\) \{/)
5927
+ assert.match(settleSrc, /if \(t\.status !== 'resolved' && t\.status !== 'cancelled' && t\.status !== 'archived'\) return/)
5928
+ assert.match(settleSrc, /if \(rec && !rec\.settled && String\(rec\.id\) === rid\) return/)
5929
+ assert.match(settleSrc, /r\.outcome = 'stale-closed'/)
5930
+ assert.match(settleSrc, /r\.endedAt = new Date\(\)\.toISOString\(\)/)
5931
+ assert.match(settleSrc, /r\.note = 'host 重启僵尸留档清扫'/)
5932
+ assert.match(settleSrc, /if \(t\.verifierRun === rid\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
5933
+ assert.match(settleSrc, /if \(t\.claimedBy === rid\) \{ t\.claimedBy = null; t\.claimedAt = null \}/)
5934
+ assert.match(settleSrc, /return any \? d : null/) // 无改动不写盘(安静板不因清扫产生心跳写盘)
5676
5935
  // ② poolCycle 挂点:reconcile 之后、幽灵回收之前(落定卡僵尸留档 reconcile/reap 都管不到,须在空闲快进前兜到)
5677
- assert.match(dsp, /try \{ await sweepZombieRuns\(sid, snap\) \} catch \(e\) \{ console\.error\('\[task-board\] 僵尸留档清扫失败:/)
5936
+ assert.match(dsp, /try \{ await settle\.sweepZombieRuns\(sid, snap\) \} catch \(e\) \{ console\.error\('\[task-board\] 僵尸留档清扫失败:/)
5678
5937
  })
5679
5938
 
5680
5939
  test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次性 + 详情页 ↻ 续跑标注 + 文档收尾', () => {
5681
5940
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5941
+ // 拆分第 7 刀(task-mvt8c1yx-7,仲裁扩权放行):usage 水位/accumulateRunUsage/reconcileRoot 随源码搬至
5942
+ // lib/settle.mjs——②③机制锁断言随源码重指 settle 源;poolCycle 挂点接线断言仍锁 dispatch 源(settle.* 形态)。
5943
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
5682
5944
  const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
5683
5945
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5684
5946
  // ① usage 增量:readRunUsage 收第三参 sinceSeq,只认水位线之后的帧,并回传全份 maxSeq
@@ -5686,24 +5948,24 @@ test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次
5686
5948
  assert.match(usageSrc, /if \(from > 0 && sq <= from\) continue/)
5687
5949
  assert.match(usageSrc, /maxSeq: -1/)
5688
5950
  // ② 结算侧:水位线从卡上 runs 条目读、增量累加后写回(只增不减)
5689
- assert.match(dsp, /function seekSeqOf\(snap, taskId, runId\) \{/)
5690
- assert.match(dsp, /since = seekSeqOf\(snap0, rec\.taskId, rec\.id\)/)
5691
- assert.match(dsp, /u = readRunUsage\(String\(rec\.id\), sessionsRoot, since\)/)
5951
+ assert.match(settleSrc, /function seekSeqOf\(snap, taskId, runId\) \{/)
5952
+ assert.match(settleSrc, /since = seekSeqOf\(snap0, rec\.taskId, rec\.id\)/)
5953
+ assert.match(settleSrc, /u = readRunUsage\(String\(rec\.id\), sessionsRoot, since\)/)
5692
5954
  // 同 id 的续跑新条目尚未结算(usageSeq 未设)→ 必须继续往前找已结算的那条,否则水位线恒 0(踩过的坑)
5693
- assert.match(dsp, /if \(!r \|\| String\(r\.id\) !== String\(runId\)\) continue\s*\n\s*var w = num0\(r\.usageSeq\)\s*\n\s*if \(w > 0\) return w/)
5694
- assert.match(dsp, /t\.runs\[ri\]\.usageSeq = Math\.max\(num0\(t\.runs\[ri\]\.usageSeq\), num0\(u\.maxSeq\)\)/)
5955
+ assert.match(settleSrc, /if \(!r \|\| String\(r\.id\) !== String\(runId\)\) continue\s*\n\s*var w = num0\(r\.usageSeq\)\s*\n\s*if \(w > 0\) return w/)
5956
+ assert.match(settleSrc, /t\.runs\[ri\]\.usageSeq = Math\.max\(num0\(t\.runs\[ri\]\.usageSeq\), num0\(u\.maxSeq\)\)/)
5695
5957
  // ③ reconcile:per-host 一次性标记 + listChildren 存活判定 + 重建 rec/重挂超时臂 + 死会话回 pending
5696
- assert.match(dsp, /async function reconcileRoot\(sid\) \{/)
5697
- assert.match(dsp, /if \(state\.reconcileDone\[sid\]\) return/)
5698
- assert.match(dsp, /typeof subagents\.listChildren !== 'function'/)
5699
- assert.match(dsp, /await withTimeout\(q, 5000, 'reconcile listChildren'\)/)
5700
- assert.match(dsp, /restored: true, ran: false/)
5701
- assert.match(dsp, /baselineBytes: logSizeOf\(childId\)/)
5702
- assert.match(dsp, /armTimeouts\(sid, rec, tc, c2\)/)
5703
- assert.match(dsp, /last\.outcome = 'timeout\/error'; last\.endedAt = new Date\(\)\.toISOString\(\)/)
5704
- assert.match(dsp, /续跑观测已恢复/)
5958
+ assert.match(settleSrc, /async function reconcileRoot\(sid\) \{/)
5959
+ assert.match(settleSrc, /if \(state\.reconcileDone\[sid\]\) return/)
5960
+ assert.match(settleSrc, /typeof subagents\.listChildren !== 'function'/)
5961
+ assert.match(settleSrc, /await withTimeout\(q, 5000, 'reconcile listChildren'\)/)
5962
+ assert.match(settleSrc, /restored: true, ran: false/)
5963
+ assert.match(settleSrc, /baselineBytes: logSizeOf\(childId\)/)
5964
+ assert.match(settleSrc, /armTimeouts\(sid, rec, tc, c2\)/)
5965
+ assert.match(settleSrc, /last\.outcome = 'timeout\/error'; last\.endedAt = new Date\(\)\.toISOString\(\)/)
5966
+ assert.match(settleSrc, /续跑观测已恢复/)
5705
5967
  // poolCycle 入口接线(在读 runs/snap 之前,重建的 rec 参与本轮活跃计数)
5706
- assert.match(dsp, /try \{ await reconcileRoot\(sid\) \} catch \(e\) \{ console\.error\('\[task-board\] reconcile 失败:'/)
5968
+ assert.match(dsp, /try \{ await settle\.reconcileRoot\(sid\) \} catch \(e\) \{ console\.error\('\[task-board\] reconcile 失败:'/)
5707
5969
  // ④ 详情页 ↻ 续跑标注(读卡2 落的 runs[i].resume,不猜;老留档零渲染)
5708
5970
  assert.match(cli, /var rsMark = r\.resume === true \? ' ↻' : ''/)
5709
5971
  assert.match(cli, /↻ 冷复活续跑:沿用上一次的子会话,非新开/)
@@ -6027,7 +6289,7 @@ test('池冻结根修⑤(接线,源码级):幽灵回收在活跃度计
6027
6289
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
6028
6290
  const sto = readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
6029
6291
  // ① 回收必须在 activeW 计数与 pickDispatch 之前(否则本轮依旧被幽灵拖住)
6030
- const iReap = dsp.indexOf('reapedN = await reapGhostRecs(sid, snap)')
6292
+ const iReap = dsp.indexOf('reapedN = await settle.reapGhostRecs(sid, snap)') // 拆分第 7 刀:挂点改经 settle 实例
6031
6293
  const iCount = dsp.indexOf('if (rc0.role === \'worker\') activeW++')
6032
6294
  assert.ok(iReap > 0 && iCount > iReap, '幽灵回收必须先于活跃度计数')
6033
6295
  // ② 回收后重读快照(否则空闲快进的 wt(snap) 会把收尾结果覆盖回旧状态)