dsh-agent-board 1.9.1 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.9.1",
4
+ "version": "1.9.2",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
@@ -20,7 +20,8 @@ import { createSession, isFullSessionId } from '../lib/session.mjs'
20
20
  import { createDispatch } from '../lib/dispatch.mjs'
21
21
  import { createNotify } from '../lib/notify.mjs'
22
22
  // host 端源码拼接(Phase 2 模块化后:薄壳 index.mjs + lib/*.mjs 领域模块),供源码级接线断言
23
- 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']
24
25
  function hostSrc() { return HOST_SOURCES.map(function (f) { return readFileSync(new URL(f, import.meta.url), 'utf8') }).join('\n') }
25
26
 
26
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 || {}) }
@@ -1085,6 +1086,38 @@ test('parsePlanFiles: 收紧——全角分隔长说明段不进清单;含路
1085
1086
  assert.deepEqual(core.parsePlanFiles('计划改动:`my docs/a b.md`(注释)'), ['my docs/a b.md'])
1086
1087
  })
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
+
1088
1121
  test('touchCovers: 单向覆盖——touch 模式 ⊇ 计划文件(与 matchOne 对称语义方向相反)', () => {
1089
1122
  assert.equal(core.touchCovers('lib/core.mjs', 'lib/core.mjs'), true) // 精确
1090
1123
  assert.equal(core.touchCovers('lib/core.mjs', 'lib/rpc.mjs'), false)
@@ -2166,10 +2199,10 @@ test('pushRejection: 同事件判重(同时间戳/同前缀不双推)+ 空
2166
2199
 
2167
2200
  test('驳回全量带回接线(源码级):三条驳回路径都调 pushRejection + messages 通道在位', () => {
2168
2201
  const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2169
- 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')
2170
2203
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
2171
2204
  assert.equal((rpc.match(/pushRejection\(t, /g) || []).length, 3) // board_verdict 工具 + verify-task RPC + task_verify 工具
2172
- 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 源
2173
2206
  assert.equal((hostSrc().match(/pushRejection\(t, /g) || []).length, 4) // 三条路径 + Verifier 文本结算
2174
2207
  assert.match(coreSrc, /export function pushRejection/)
2175
2208
  assert.match(coreSrc, /kind: 'rejection'/)
@@ -2656,21 +2689,22 @@ test('README 双份同步记录 Token 口径(总量主显 / 近 7 天 / 有效
2656
2689
  })
2657
2690
 
2658
2691
  test('Token run 级留账:accumulateRunUsage 把本次用量写回对应 t.runs 条目(源码级断言)', () => {
2659
- 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')
2660
2694
  // 五分量原样写回 runs 条目:任何维度(按天/按模型/按阶段)都能从 runs 精确重建,不必回头猜
2661
- assert.match(dsp, /if \(Array\.isArray\(t\.runs\)\)/)
2662
- assert.match(dsp, /if \(String\(t\.runs\[ri\] && t\.runs\[ri\]\.id\) === String\(rec\.id\)\)/)
2663
- 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 \}/)
2664
- 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 只记一次)
2665
2699
  // 倒序查找 + 找不到就跳过:runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,正常必存在
2666
- 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--\)/)
2667
2701
  // 有效消耗口径单点定义(host 侧),byDay 双指标记账与它同源
2668
- assert.match(dsp, /function effectiveOf\(u\) \{/)
2669
- assert.match(dsp, /return \(u\.input \|\| 0\) \+ \(u\.output \|\| 0\) \+ \(u\.cacheWrite \|\| 0\)/)
2670
- 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\)/)
2671
2705
  // byDay 双指标:总 t / 有效 e 各自累加;老 number 形态脏值就地收敛成对象(不把老字段形态写坏)
2672
- assert.match(dsp, /if \(!cell \|\| typeof cell !== 'object'\) cell = \{ t: Number\(cell\) \|\| 0, e: 0 \}/)
2673
- 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/)
2674
2708
  })
2675
2709
 
2676
2710
  test('readRunUsage: run 不存在 / 日志目录不可读 → null(优雅降级,不抛错)', () => {
@@ -2725,9 +2759,10 @@ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表
2725
2759
  assert.match(host, /cell\.e = \(Number\(cell\.e\) \|\| 0\) \+ eff/) // 同一笔记一笔有效消耗
2726
2760
  assert.match(host, /var dk = localDayKey\(\)/) // 键取结算时刻本地日
2727
2761
  // 本地日口径:localDayKey 必须用本地 getters 拼(含 getFullYear/getMonth/getDate),不得用 toISOString
2728
- const dispatchSrc = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2729
- const dkBody = dispatchSrc.match(/function localDayKey\(d\) \{[\s\S]{0,300}?\n \}/)
2730
- 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')
2731
2766
  assert.match(dkBody[0], /getFullYear\(\)/)
2732
2767
  assert.match(dkBody[0], /getMonth\(\) \+ 1/)
2733
2768
  assert.match(dkBody[0], /getDate\(\)/)
@@ -3844,28 +3879,29 @@ test('可续跑 Worker⑤:idle 后从子会话 v4 日志读到助手文本 →
3844
3879
 
3845
3880
  test('可续跑 Worker 接线(源码级):事件订阅走 ctx.effect 回收 + 硬超时 interrupt 留存 + 重派续跑接线', () => {
3846
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')
3847
3883
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
3848
3884
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
3849
3885
  // ① 双路 spawn:Worker+开关开才走 startContinuable(verifier/hook 不受影响)
3850
3886
  assert.match(dsp, /var useContinuable = role === 'worker' && c\.workerContinuable !== false/)
3851
3887
  assert.match(dsp, /var cs = await subagents\.startContinuable\(\{ provider: providerName, label: req\.label, request: req, signal: req\.signal \}\)/)
3852
3888
  // ② 结算观测:agent/status 订阅 + running→idle + rec.ran 二次守卫 + effect 回收
3853
- assert.match(dsp, /ctx\.effect\(function \(\) \{ return ctx\.on\('agent\/status', onAgentStatus\) \}\)/)
3854
- assert.match(dsp, /if \(status !== 'idle' && status !== 'running'\) return/) // running 事件必须放行(rec.ran 的唯一来源)
3855
- assert.match(dsp, /if \(status === 'running'\) \{ rec\.ran = true; return \}/)
3856
- assert.match(dsp, /if \(!rec\.ran \|\| rec\.settled\) return/)
3857
- 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\)/)
3858
3894
  // ③ 卡2 Step1:硬超时/失败结算对 continuable 子会话改 interrupt(留存不销毁),一次性仍 dispose;
3859
3895
  // authority 形状取 dsh-subagent 的 SubagentInterruptAuthority,interrupt 异常被吞进 resumeBlocked 不阻断结算。
3860
- assert.match(dsp, /async function endContinuable\(sid, rec\) \{/)
3861
- assert.match(dsp, /subagents\.interrupt\(rec\.childId, \{ kind: 'ancestor', agent: parent \}\)/)
3862
- assert.match(dsp, /if \(rec\.continuable\) await endContinuable\(sid, rec\)\s*\n\s*else await rec\.run\.dispose\(\)/)
3863
- 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/)
3864
3900
  assert.match(dsp, /withTimeout\(abortableP, hardMs, rec\.role \+ ':' \+ t\.id\)/)
3865
3901
  // ④ 文本兜底复用 usage.mjs 现成读取器(与 agent-activity 同一套日志定位/分帧)+ 续跑基线(只认本轮新写字节)
3866
- assert.match(dsp, /var log = findRunLog\(childId, sessionsRoot\)/)
3867
- assert.match(dsp, /var buf = readLogBytes\(log, tail\)/)
3868
- 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\)/)
3869
3905
  assert.match(dsp, /baselineBytes: logSizeOf\(childId\)/)
3870
3906
  // ⑤ 卡2 Step2/Step3:重派续跑接线(sendMessage 冷复活 + 薄框架断点续跑指令 + runs[] 续跑记录 +
3871
3907
  // 失败回退 fresh spawn)+ 三连败计数不受续跑影响(续跑同样走 settleWorker 的 retryCount 口径)
@@ -4186,12 +4222,13 @@ test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run →
4186
4222
 
4187
4223
  test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
4188
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')
4189
4226
  // spawnOneShot:hook 角色走 buildHookPrompt(phase 由 role 决定)
4190
4227
  assert.match(dsp, /if \(role === 'hook-pre' \|\| role === 'hook-post'\) \{/)
4191
4228
  assert.match(dsp, /buildHookPrompt\(t, role === 'hook-pre' \? 'pre' : 'post', kids\)/)
4192
- // settleRun:hook 分支进 settleHook(照 worker/verifier 结算模式)
4193
- assert.match(dsp, /else if \(rec\.role === 'hook-pre' \|\| rec\.role === 'hook-post'\) await settleHook\(sid, rec, output, failed, errText\)/)
4194
- 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 源
4195
4232
  // pre 闸门占用(state=running 即幂等占用)与 post 补 spawn(pending 标记驱动,复用 verifierRun 幂等位)
4196
4233
  assert.match(dsp, /if \(hookOn\(t, 'pre'\) && t\.hooks\.pre\.state === 'idle' && !runs\[t\.id\]\) \{/)
4197
4234
  assert.match(dsp, /hookSetState\(t, 'pre', 'running', 'system', '派发前置 hook run'\)/)
@@ -4236,11 +4273,12 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
4236
4273
  // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、
4237
4274
  // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)、
4238
4275
  // 自测指南归一(normalizeUserTest,task-muxyyvg0)扩展;
4239
- // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)、
4276
+ // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)、
4240
4277
  // depsSatisfied(task-mv9yupm4,spawn 落闸锁内补验依赖)、
4241
4278
  // Verifier 验收员加餐常量(VERIFIER_PERSONA/VERIFIER_TOOL_FILTER,task-muy3gm03)、
4242
- // 人工/外部操作留痕单行摘要(opsLogLine,task-mvnf3gly,Team 注入段「最近人工/外部操作」小节)
4243
- 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/)
4244
4282
  // rpc.mjs 解构表尾部再随建卡派发预警(appendDispatchWarning,task-mvbealtf)、力度路由档位表(TASK_SIZES,task-mvnemvf5)、
4245
4283
  // 人工/外部操作留痕单行摘要(opsLogLine,task-mvnf3gly,task_list recentOps 透出)扩展
4246
4284
  assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint, appendDispatchWarning, TASK_SIZES, opsLogLine \} = core/)
@@ -4508,11 +4546,11 @@ test('拆解回执⑤:notifyDone=false → 拆解回执不入队;源卡 mess
4508
4546
  })
4509
4547
 
4510
4548
  test('拆解回执⑧:settleDecompose 三分支与 index 接线在位(源面断言,防接线回退)', () => {
4511
- const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
4549
+ const settleSrc = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8') // 拆分第 6 刀:settleDecompose 随源码重指 settle 源
4512
4550
  // split/keep 成功走 notifyDecomposed(notifyDone 闸门 + 老 host 未注入静默),失败分支保持 🚨 pushSysNote
4513
- assert.match(dsp, /if \(doneOn && typeof notifyDecomposed === 'function'\) notifyDecomposed\(sid, \{ sourceId: rec\.taskId, sourceTitle: srcTitle, verdict: 'split'/)
4514
- assert.match(dsp, /if \(doneOn && typeof notifyDecomposed === 'function'\) notifyDecomposed\(sid, \{ sourceId: rec\.taskId, sourceTitle: srcTitle, verdict: 'keep'/)
4515
- assert.match(dsp, /else pushSysNote\(sid, '🚨 拆解官 run 未产出可用提案(源卡 ' \+ rec\.taskId \+ '):'/)
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 \+ '):'/)
4516
4554
  const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
4517
4555
  assert.match(idx, /notifyDecomposed: notify\.notifyDecomposed/) // index.mjs 接线在位
4518
4556
  })
@@ -4605,17 +4643,17 @@ test('回执开关③b:notifyDone=false → worker 三连败转 blocked 也不
4605
4643
  })
4606
4644
 
4607
4645
  test('回执开关③/④:完成/阻塞回执闸门在结算调用方;歧义裁决通道零开关(源码级断言)', () => {
4608
- 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 源
4609
4647
  const noti = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
4610
4648
  // 完成回执:四个结算函数(worker/verifier/hook + 拆解官 decompose,task-mvrqiu6x 起后者也受 notifyDone 约束)
4611
4649
  // 都在同一次持锁回调里取 notifyDone(零额外读盘)
4612
- assert.equal((dsp.match(/doneOn = cfg\(d\)\.notifyDone !== false/g) || []).length, 4)
4613
- assert.equal((dsp.match(/if \(doneOn && /g) || []).length, 7) // worker 2 + verifier 2 + hook-closed 1 + decompose split/keep 2
4614
- assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'resolved'\) notifyTaskDone\(sid, result\.task, 'resolved'\)/)
4615
- assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'blocked'\) notifyTaskDone\(sid, result\.task, 'blocked'\)/)
4616
- assert.match(dsp, /if \(result\.blocked\) \{ maybeNotify\(sid, result\.task\); if \(doneOn\) notifyTaskDone\(sid, result\.task, 'blocked'\) \}/)
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'\) \}/)
4617
4655
  // 闸门默认 true:回调未跑到也不漏报(宁可多报)
4618
- assert.equal((dsp.match(/var doneOn = true/g) || []).length, 4) // worker/verifier/hook 各一 + settleDecompose 一
4656
+ assert.equal((settleSrc.match(/var doneOn = true/g) || []).length, 4) // worker/verifier/hook 各一 + settleDecompose 一
4619
4657
  // ④ 歧义通道零开关:通知层根本读不到板级配置(cfg 未引入),去注释后的上报代码段不含两个开关名
4620
4658
  assert.doesNotMatch(noti, /cfg\(/)
4621
4659
  const escBlock = noti.slice(noti.indexOf('// 歧义上报通知'), noti.indexOf('// ===== 任务回执通知'))
@@ -4623,7 +4661,7 @@ test('回执开关③/④:完成/阻塞回执闸门在结算调用方;歧义
4623
4661
  const escCode = escBlock.split('\n').filter(l => !/^\s*\/\//.test(l)).join('\n')
4624
4662
  assert.doesNotMatch(escCode, /notifyDispatch|notifyDone/)
4625
4663
  // 三个结算点的歧义通知调用(maybeNotify)都不带任何开关闸门
4626
- assert.equal((dsp.match(/maybeNotify\(sid, result\.task\)/g) || []).length, 3)
4664
+ assert.equal((settleSrc.match(/maybeNotify\(sid, result\.task\)/g) || []).length, 3)
4627
4665
  })
4628
4666
 
4629
4667
  test('回执开关⑤:设置弹层「通知」小节两行开关 + set-board-config 读写通道(源码 + RPC 行为)', async () => {
@@ -5881,22 +5919,28 @@ test('僵尸 run 留档清扫:落定卡 running 无 endedAt 留档被关账+
5881
5919
 
5882
5920
  test('僵尸 run 留档清扫接线(源码级):sweepZombieRuns 定义 + poolCycle 挂点 + 只清扫落定卡门禁', () => {
5883
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')
5884
5925
  // ① 清扫函数定义(含「只对落定卡做」状态门禁与「无活跃 rec 才扫」护栏)
5885
- assert.match(dsp, /async function sweepZombieRuns\(sid, snap\) \{/)
5886
- assert.match(dsp, /if \(t\.status !== 'resolved' && t\.status !== 'cancelled' && t\.status !== 'archived'\) return/)
5887
- assert.match(dsp, /if \(rec && !rec\.settled && String\(rec\.id\) === rid\) return/)
5888
- assert.match(dsp, /r\.outcome = 'stale-closed'/)
5889
- assert.match(dsp, /r\.endedAt = new Date\(\)\.toISOString\(\)/)
5890
- assert.match(dsp, /r\.note = 'host 重启僵尸留档清扫'/)
5891
- assert.match(dsp, /if \(t\.verifierRun === rid\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
5892
- assert.match(dsp, /if \(t\.claimedBy === rid\) \{ t\.claimedBy = null; t\.claimedAt = null \}/)
5893
- 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/) // 无改动不写盘(安静板不因清扫产生心跳写盘)
5894
5935
  // ② poolCycle 挂点:reconcile 之后、幽灵回收之前(落定卡僵尸留档 reconcile/reap 都管不到,须在空闲快进前兜到)
5895
- 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\] 僵尸留档清扫失败:/)
5896
5937
  })
5897
5938
 
5898
5939
  test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次性 + 详情页 ↻ 续跑标注 + 文档收尾', () => {
5899
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')
5900
5944
  const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
5901
5945
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5902
5946
  // ① usage 增量:readRunUsage 收第三参 sinceSeq,只认水位线之后的帧,并回传全份 maxSeq
@@ -5904,24 +5948,24 @@ test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次
5904
5948
  assert.match(usageSrc, /if \(from > 0 && sq <= from\) continue/)
5905
5949
  assert.match(usageSrc, /maxSeq: -1/)
5906
5950
  // ② 结算侧:水位线从卡上 runs 条目读、增量累加后写回(只增不减)
5907
- assert.match(dsp, /function seekSeqOf\(snap, taskId, runId\) \{/)
5908
- assert.match(dsp, /since = seekSeqOf\(snap0, rec\.taskId, rec\.id\)/)
5909
- 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\)/)
5910
5954
  // 同 id 的续跑新条目尚未结算(usageSeq 未设)→ 必须继续往前找已结算的那条,否则水位线恒 0(踩过的坑)
5911
- 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/)
5912
- 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\)\)/)
5913
5957
  // ③ reconcile:per-host 一次性标记 + listChildren 存活判定 + 重建 rec/重挂超时臂 + 死会话回 pending
5914
- assert.match(dsp, /async function reconcileRoot\(sid\) \{/)
5915
- assert.match(dsp, /if \(state\.reconcileDone\[sid\]\) return/)
5916
- assert.match(dsp, /typeof subagents\.listChildren !== 'function'/)
5917
- assert.match(dsp, /await withTimeout\(q, 5000, 'reconcile listChildren'\)/)
5918
- assert.match(dsp, /restored: true, ran: false/)
5919
- assert.match(dsp, /baselineBytes: logSizeOf\(childId\)/)
5920
- assert.match(dsp, /armTimeouts\(sid, rec, tc, c2\)/)
5921
- assert.match(dsp, /last\.outcome = 'timeout\/error'; last\.endedAt = new Date\(\)\.toISOString\(\)/)
5922
- 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, /续跑观测已恢复/)
5923
5967
  // poolCycle 入口接线(在读 runs/snap 之前,重建的 rec 参与本轮活跃计数)
5924
- 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 失败:'/)
5925
5969
  // ④ 详情页 ↻ 续跑标注(读卡2 落的 runs[i].resume,不猜;老留档零渲染)
5926
5970
  assert.match(cli, /var rsMark = r\.resume === true \? ' ↻' : ''/)
5927
5971
  assert.match(cli, /↻ 冷复活续跑:沿用上一次的子会话,非新开/)
@@ -6245,7 +6289,7 @@ test('池冻结根修⑤(接线,源码级):幽灵回收在活跃度计
6245
6289
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
6246
6290
  const sto = readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
6247
6291
  // ① 回收必须在 activeW 计数与 pickDispatch 之前(否则本轮依旧被幽灵拖住)
6248
- const iReap = dsp.indexOf('reapedN = await reapGhostRecs(sid, snap)')
6292
+ const iReap = dsp.indexOf('reapedN = await settle.reapGhostRecs(sid, snap)') // 拆分第 7 刀:挂点改经 settle 实例
6249
6293
  const iCount = dsp.indexOf('if (rc0.role === \'worker\') activeW++')
6250
6294
  assert.ok(iReap > 0 && iCount > iReap, '幽灵回收必须先于活跃度计数')
6251
6295
  // ② 回收后重读快照(否则空闲快进的 wt(snap) 会把收尾结果覆盖回旧状态)
@@ -11,16 +11,19 @@
11
11
  // ④ 测试分层纪律节——事故复盘双落纪律(单测断言锁机制 + fault-e2e-* 场景锁接缝)+
12
12
  // 新事故类加卡模板指向 fault-e2e-recovery.test.mjs;
13
13
  // ⑤ 关键契约词全文在案——startActivation / listProviders / session.v4.jsonl.zstd /
14
- // 五档故障名(instant-kill/hang/empty/rate-limit/slow)+ 消费点行号锚;
14
+ // 五档故障名(instant-kill/hang/empty/rate-limit/slow)+ 消费点「文件 + 符号」锚;
15
15
  // ⑥ 文档与源码同口径——五档故障名逐一在 fault-provider.mjs FAULT_TYPES 常量里(源码真值),
16
- // dispatch.mjs 消费点符号(hasActivationApi/activationAsRun/modelDirectory/probeModel)在案,
17
- // 文档行号锚不悬空。
16
+ // 消费点符号在案(dispatch.mjs: hasActivationApi/activationAsRun/activationRequestOf;拆分后
17
+ // model-health.mjs: modelDirectory/probeModel),文档锚不悬空。
18
+ // ⑦ 行号锚清扫锁(task-mvt8c1yx-8 拆分第 8 刀):拆分令行号持续漂移,文档一律按「文件 + 函数/
19
+ // 符号名」定位——旧行号锚(L806-823/L163-178/L294-332)与任何 L<行号> 形式一律不得回潮。
18
20
  import { test } from 'node:test'
19
21
  import assert from 'node:assert/strict'
20
22
  import { readFileSync } from 'node:fs'
21
23
 
22
24
  const doc = readFileSync(new URL('../docs/fault-injection.md', import.meta.url), 'utf8')
23
25
  const dispatch = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
26
+ const modelHealth = readFileSync(new URL('../lib/model-health.mjs', import.meta.url), 'utf8')
24
27
  const faultProvider = readFileSync(new URL('./helpers/fault-provider.mjs', import.meta.url), 'utf8')
25
28
 
26
29
  test('①文档存在 + 三节锚点标题在案(A/B 权衡记录 / B 路径接缝契约 / 测试分层纪律)', () => {
@@ -89,16 +92,27 @@ test('④测试分层纪律节:事故复盘双落纪律 + 新事故类加卡
89
92
  ]) assert.ok(doc.includes(m), '测试分层纪律节缺锚点:' + m)
90
93
  })
91
94
 
92
- test('⑤关键契约词全文在案:五档故障名 + startActivation/listProviders/session.v4.jsonl.zstd + 行号锚', () => {
95
+ test('⑤关键契约词全文在案:五档故障名 + startActivation/listProviders/session.v4.jsonl.zstd + 符号锚', () => {
93
96
  for (const n of ['instant-kill', 'hang', 'empty', 'rate-limit', 'slow']) {
94
97
  assert.ok(doc.includes(n), '文档缺五档故障名:' + n)
95
98
  }
96
99
  for (const m of ['startActivation', 'listProviders', 'session.v4.jsonl.zstd']) {
97
100
  assert.ok(doc.includes(m), '文档缺关键契约词:' + m)
98
101
  }
99
- for (const m of ['L806-823', 'L163-178', 'L294-332']) {
100
- assert.ok(doc.includes(m), '文档缺消费点行号锚:' + m)
102
+ // 消费点锚一律「文件 + 函数/符号名」口径(task-mvt8c1yx-8):拆分后行号必漂,符号不漂。
103
+ for (const m of ['lib/dispatch.mjs', 'lib/model-health.mjs', 'spawnOneShot', 'hasActivationApi',
104
+ 'activationAsRun', 'activationRequestOf', 'modelDirectory()', 'probeModel()']) {
105
+ assert.ok(doc.includes(m), '文档缺消费点符号锚:' + m)
101
106
  }
107
+ for (const dead of ['L806-823', 'L163-178', 'L294-332']) {
108
+ assert.ok(!doc.includes(dead), '文档残留旧行号锚(拆分后已漂移,按符号定位):' + dead)
109
+ }
110
+ assert.ok(!/L\d{2,4}/.test(doc), '文档不得再出现 L<行号> 形式锚点(历次拆分会令其漂移)')
111
+ })
112
+
113
+ test('⑦行号锚清扫:lib/ 与 docs/ 不再有指向 dispatch.mjs 的 L<行号> 锚(预研清单语法说明除外)', () => {
114
+ assert.ok(!/dispatch\.mjs[^\n]*L\d/.test(doc), 'fault-injection.md 残留 dispatch.mjs 行号锚')
115
+ assert.ok(!/dispatch[^\n]*\s\d{3}\b/.test(doc), 'fault-injection.md 残留「dispatch <行号>」式锚')
102
116
  })
103
117
 
104
118
  test('⑥文档与源码同口径:五档在 FAULT_TYPES 常量里(源码真值)+ dispatch.mjs 消费点符号在案', () => {
@@ -107,8 +121,12 @@ test('⑥文档与源码同口径:五档在 FAULT_TYPES 常量里(源码真
107
121
  for (const n of ['instant-kill', 'hang', 'empty', 'rate-limit', 'slow']) {
108
122
  assert.ok(types[1].includes("'" + n + "'"), 'FAULT_TYPES 缺五档故障名:' + n)
109
123
  }
110
- // 文档引用的行号锚不悬空:引用的符号在源码里逐一在案(符号级同口径)
111
- for (const s of ['hasActivationApi', 'activationAsRun', 'activationRequestOf', 'modelDirectory', 'probeModel', 'startActivation']) {
124
+ // 文档引用的消费点符号不悬空(符号级同口径):spawn/activation 面仍在 dispatch.mjs,
125
+ // 模型目录/探测面已拆至 lib/model-health.mjs(第 2/4 刀)——文档锚指到新文件,此处回源码复检。
126
+ for (const s of ['hasActivationApi', 'activationAsRun', 'activationRequestOf', 'startActivation']) {
112
127
  assert.ok(dispatch.includes(s), 'dispatch.mjs 缺消费点符号:' + s)
113
128
  }
129
+ for (const s of ['function modelDirectory', 'function probeModel']) {
130
+ assert.ok(modelHealth.includes(s), 'lib/model-health.mjs 缺消费点符号:' + s)
131
+ }
114
132
  })
@@ -779,6 +779,129 @@ test('⑰c超时不覆盖既有观测:先 healthy 再超时→仍 healthy+note
779
779
  } finally { console.error = origErr }
780
780
  })
781
781
 
782
+ // ===== ⑬f 探测恢复熔断解除(task-mvvw71nc):连续 2 次探测通过翻绿,红态可证活 =====
783
+ // 用户 2026-10-31 实测:「配置模型熔断后状态变红,再下一个探测周期是否会变回来,实测没有」——旧 ok 臂只写
784
+ // observed='healthy',从不碰 badModels 熔断表,syncModelHealth 覆盖闸每次重查熔断表压回 circuit(探测能证
785
+ // 「可调用」却解除不了「不可调」判定)。修法(用户拍板「乙+连击」):探测 ok 臂累计 probeOkStreak,连续 2 次
786
+ // (15min 双周期确认)→ delete badModels + 连击清零 + 重派生 + 通知;中断(失败/超时/degraded)计数清零;
787
+ // 单次成功不复位(不放过「能回 1 token 但跑长任务就崩」的偶发病模型)。
788
+ function mkRecoverState(k) {
789
+ var st = { modelHealth: {}, modelCooldown: {}, badModels: {}, probeOkStreak: {}, modelFailStreak: {} }
790
+ st.badModels[k] = true
791
+ st.modelFailStreak[k] = 3
792
+ st.modelHealth[SID] = {}
793
+ st.modelHealth[SID]['p/a'] = { state: 'circuit', observed: 'healthy', observedNote: '主动探测通过', lastProbe: '2026-10-30T00:00:00.000Z', lastSeen: '2026-10-30T00:00:00.000Z', circuitNote: '连续 3 次 spawn/续跑即被宿主拆除(模型失败),已熔断', recentRuns: [] }
794
+ return st
795
+ }
796
+ // 捕获 pushSysNote 的派发桩(mkDispatch 的 pushSysNote 是 no-op,恢复通知投递断言需要显式捕获);
797
+ // 接线与 mkDispatch 同形,state 各表按引用共享(probeOkStreak 由测试预置,工厂就地取用同一引用)。
798
+ function mkRecoverDispatch(board, ctxOver, state, notes) {
799
+ var shared = Object.assign({ knownSessions: {}, dispatchedEver: {}, teamModeCache: {}, activeRuns: {} }, state)
800
+ return createDispatch(
801
+ Object.assign({ fs: {}, effect: function () {}, get: function () { return null } }, ctxOver || {}),
802
+ shared,
803
+ {
804
+ rt: async function () { return board }, wt: async function () {},
805
+ mutateLocked: async function (sid, fn) { return fn(board) },
806
+ kickCycle: function () {}, rootForSession: function () { return { id: SID } },
807
+ sessionCwd: function () { return '' }, withTimeout: function (p) { return p }, runsFor: function (sid) { return shared.activeRuns[sid] || (shared.activeRuns[sid] = {}) },
808
+ feedbackOn: function () { return true }, pushSysNote: function (sid, text) { notes.push({ sid: sid, text: String(text) }) }, maybeNotify: function () {}, notifyTaskDone: function () {},
809
+ })
810
+ }
811
+
812
+ test('⑬f探测恢复熔断解除:单次通过不复位;连续 2 次→badModels 清除+连击清零+翻 healthy+通知投递', async () => {
813
+ var board = seed(SID); board.workerModel = 'p/a'
814
+ var k = SID + '|p/a'
815
+ var origErr = console.error; console.error = function () {}
816
+ try {
817
+ // ① 已熔断 + 探测成功 1 次 → 仍 circuit(计数 1,badModels/连击未动,零通知)
818
+ var calls = [], notes = [], st = mkRecoverState(k)
819
+ var d = mkRecoverDispatch(board, mkProbeScriptCtx(calls, ['stop']), st, notes)
820
+ var r1 = await d.probeConfiguredModels(SID, board)
821
+ assert.equal(r1.healthy, 1)
822
+ assert.equal(st.badModels[k], true, '单次探测成功不解除熔断(过滤偶发响应)')
823
+ assert.equal(st.probeOkStreak[k], 1, '恢复连击累计 1')
824
+ assert.equal(st.modelFailStreak[k], 3, '熔断连击未被单次探测清零')
825
+ assert.equal(st.modelHealth[SID]['p/a'].state, 'circuit', '覆盖闸仍压回 circuit(半程红态)')
826
+ assert.equal(st.modelHealth[SID]['p/a'].observed, 'healthy', 'observed 已翻 healthy(覆盖态仍 circuit)')
827
+ assert.equal(notes.length, 0, '未复位不发通知')
828
+
829
+ // ② 连续第 2 次 → 解除:badModels 清除 + 连击清零 + 恢复计数清零 + state 翻 healthy + 通知投递
830
+ var r2 = await d.probeConfiguredModels(SID, board)
831
+ assert.equal(r2.healthy, 1)
832
+ assert.equal(k in st.badModels, false, '连续 2 次探测通过 → 解除熔断')
833
+ assert.equal(k in st.modelFailStreak, false, '熔断连击清零(复位对称操作)')
834
+ assert.equal(k in st.probeOkStreak, false, '恢复连击复位后清零')
835
+ assert.equal(st.modelHealth[SID]['p/a'].state, 'healthy', '熔断解除后重派生翻绿(🔴→🟢)')
836
+ assert.equal(st.modelHealth[SID]['p/a'].observed, 'healthy')
837
+ assert.equal(notes.length, 1, '解除熔断投递系统通知')
838
+ assert.equal(notes[0].sid, SID)
839
+ assert.equal(notes[0].text, '模型 p/a 连续 2 次探测通过,熔断已解除,恢复调度')
840
+
841
+ // ⑤ 解除后再探测成功:未熔断 → 零副作用(不重建计数/不重复通知)
842
+ await d.probeConfiguredModels(SID, board)
843
+ assert.equal(k in st.probeOkStreak, false, '未熔断模型探测成功零副作用(不建多余计数)')
844
+ assert.equal(notes.length, 1, '未熔断不再发通知')
845
+ } finally { console.error = origErr }
846
+ })
847
+
848
+ test('⑬f2探测恢复中断清零:中途一次失败→计数清零重新计;degraded 🟡 不计入不复位', async () => {
849
+ var board = seed(SID); board.workerModel = 'p/a'
850
+ var k = SID + '|p/a'
851
+ var origErr = console.error; console.error = function () {}
852
+ try {
853
+ // ③ 成功 1 次 → 失败 1 次(计数清零)→ 成功 1 次:仍 circuit 且计数 1(必须真的连续,偶发成功不叠加)
854
+ var calls = [], notes = [], st = mkRecoverState(k)
855
+ var d = mkRecoverDispatch(board, mkProbeScriptCtx(calls, ['stop', { error: 'boom' }, 'stop', 'stop']), st, notes)
856
+ await d.probeConfiguredModels(SID, board)
857
+ assert.equal(st.probeOkStreak[k], 1)
858
+ var rf = await d.probeConfiguredModels(SID, board)
859
+ assert.equal(rf.healthy, 0)
860
+ assert.equal(k in st.probeOkStreak, false, '中途一次探测失败 → 恢复计数清零')
861
+ assert.equal(st.badModels[k], true, '失败不复位熔断')
862
+ await d.probeConfiguredModels(SID, board)
863
+ assert.equal(st.probeOkStreak[k], 1, '清零后重新计(不是累计 2)')
864
+ assert.equal(st.badModels[k], true, '仍需再连续一次才解除')
865
+ assert.equal(st.modelHealth[SID]['p/a'].state, 'circuit')
866
+ assert.equal(notes.length, 0)
867
+ await d.probeConfiguredModels(SID, board)
868
+ assert.equal(k in st.badModels, false, '清零重计后连续 2 次才解除')
869
+ assert.equal(notes.length, 1)
870
+
871
+ // ④ degraded 🟡(纯空响应供应商怪癖)非探测成功 → 不复位且计数清零(只有 🟢 通过才算证活)
872
+ var calls2 = [], notes2 = [], st2 = mkRecoverState(k)
873
+ var d2 = mkRecoverDispatch(board, mkProbeScriptCtx(calls2, ['stop', 'empty', 'empty']), st2, notes2)
874
+ await d2.probeConfiguredModels(SID, board)
875
+ assert.equal(st2.probeOkStreak[k], 1)
876
+ var rd = await d2.probeConfiguredModels(SID, board)
877
+ assert.equal(rd.degraded, 1, '纯空响应判 🟡degraded 存活')
878
+ assert.equal(k in st2.probeOkStreak, false, 'degraded 不计入恢复连击(清零)')
879
+ assert.equal(st2.badModels[k], true, 'degraded 不复位熔断')
880
+ assert.equal(st2.modelHealth[SID]['p/a'].state, 'circuit', '熔断覆盖态仍在(degraded 未解除)')
881
+ assert.equal(notes2.length, 0, 'degraded 不发解除通知')
882
+
883
+ // ④b 探测超时(判定保留臂)同样非成功 → 计数清零不复位
884
+ var calls4 = [], notes4 = [], st4 = mkRecoverState(k)
885
+ await mkRecoverDispatch(board, mkProbeScriptCtx(calls4, ['stop']), st4, notes4).probeConfiguredModels(SID, board)
886
+ assert.equal(st4.probeOkStreak[k], 1)
887
+ var ctx4 = mkRecordTimerCtx(mkProbeScriptCtx(calls4, ['hang']), [])
888
+ await mkRecoverDispatch(board, ctx4, st4, notes4).probeConfiguredModels(SID, board)
889
+ assert.equal(k in st4.probeOkStreak, false, '探测超时(判定保留)同样不计入恢复连击')
890
+ assert.equal(st4.badModels[k], true, '超时不复位熔断')
891
+ assert.equal(st4.modelHealth[SID]['p/a'].state, 'circuit')
892
+ assert.equal(notes4.length, 0)
893
+
894
+ // ⑤ 未熔断模型:探测成功零副作用(不建计数/不通知)
895
+ var calls3 = [], notes3 = [], st3 = { modelHealth: {}, modelCooldown: {}, badModels: {}, probeOkStreak: {}, modelFailStreak: {} }
896
+ var d3 = mkRecoverDispatch(board, mkProbeScriptCtx(calls3, ['stop', 'stop']), st3, notes3)
897
+ await d3.probeConfiguredModels(SID, board)
898
+ await d3.probeConfiguredModels(SID, board)
899
+ assert.deepEqual(Object.keys(st3.probeOkStreak), [], '未熔断模型探测成功零副作用(不建多余计数)')
900
+ assert.deepEqual(Object.keys(st3.badModels), [])
901
+ assert.equal(notes3.length, 0, '未熔断无通知')
902
+ } finally { console.error = origErr }
903
+ })
904
+
782
905
  async function runVerifierHealth(result) {
783
906
  var settle = null, pending = new Promise(function (resolve) { settle = resolve })
784
907
  var board = seed(SID); board.verifierModel = 'p/v'; board.tasks = [mkTask({ status: 'verifying' })]
@@ -168,8 +168,9 @@ test('⑤b 终止未生效 hint:terminateFailNote → err 级红条(sticky
168
168
  // ===== ⑥ host 挂点源码断言 =====
169
169
  test('⑥ host 挂点源码断言:dispatch 心跳打点 / rpc 接线 / index state 容器', function () {
170
170
  var srcDispatch = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
171
+ var srcSettle = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
171
172
  assert.ok(srcDispatch.indexOf('poolLastOkAt') >= 0, 'dispatch.mjs 应有 poolCycle 成功轮心跳打点')
172
- assert.ok(srcDispatch.indexOf('settleLastOkAt') >= 0, 'dispatch.mjs 应有 settleRunRecord 打点')
173
+ assert.ok(srcSettle.indexOf('settleLastOkAt') >= 0, 'settle.mjs 应有 settleRunRecord 打点')
173
174
  assert.ok(srcDispatch.indexOf('reapNote') >= 0, 'dispatch.mjs 应有幽灵回收一次性记录打点')
174
175
  assert.ok(srcDispatch.indexOf('dispatchOk') >= 0, 'dispatch.mjs 应有成功派发计数')
175
176
  var srcRpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
@@ -196,7 +196,8 @@ test('卡1④:helper 纯函数零 IO——fs 门面全部打爆后照跑不触
196
196
 
197
197
  // ===== ⑤ 补落账接线(源码级断言)=====
198
198
  test('卡1⑤:模型补落账接线——accumulateRunUsage 回填空 model / index re-export / helper 落在 usage.mjs', () => {
199
- const disp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
199
+ // 拆分第 7 刀(task-mvt8c1yx-7,仲裁扩权放行):accumulateRunUsage 随源码搬至 lib/settle.mjs——断言随源码重指 settle 源
200
+ const disp = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
200
201
  const usage = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
201
202
  const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
202
203
  // 补落账:run 级留账写回时,条目 model 为空且日志有模型名 → 回填(只填空不覆盖)