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/README.md +10 -5
- package/lib/core.mjs +13 -2
- package/lib/dispatch.mjs +103 -1544
- package/lib/hung.mjs +202 -0
- package/lib/model-health.mjs +553 -0
- package/lib/rpc.mjs +7 -3
- package/lib/settle.mjs +954 -0
- package/package.json +1 -1
- package/test/core.test.mjs +117 -73
- package/test/fault-injection-docs.test.mjs +26 -8
- package/test/lane-gui-bysize.test.mjs +123 -0
- package/test/runtime-health.test.mjs +2 -1
- package/test/scorecard-data.test.mjs +2 -1
- package/test/settle-stop-interrupt.test.mjs +20 -11
package/package.json
CHANGED
package/test/core.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
2662
|
-
assert.match(
|
|
2663
|
-
assert.match(
|
|
2664
|
-
assert.match(
|
|
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(
|
|
2700
|
+
assert.match(settleSrc, /for \(var ri = t\.runs\.length - 1; ri >= 0; ri--\)/)
|
|
2667
2701
|
// 有效消耗口径单点定义(host 侧),byDay 双指标记账与它同源
|
|
2668
|
-
assert.match(
|
|
2669
|
-
assert.match(
|
|
2670
|
-
assert.match(
|
|
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(
|
|
2673
|
-
assert.match(
|
|
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
|
-
|
|
2729
|
-
const
|
|
2730
|
-
|
|
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(
|
|
3855
|
-
assert.match(
|
|
3856
|
-
assert.match(
|
|
3857
|
-
assert.match(
|
|
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(
|
|
3861
|
-
assert.match(
|
|
3862
|
-
assert.match(
|
|
3863
|
-
assert.match(
|
|
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(
|
|
3867
|
-
assert.match(
|
|
3868
|
-
assert.match(
|
|
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
|
|
4193
|
-
assert.match(
|
|
4194
|
-
assert.match(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
4514
|
-
assert.match(
|
|
4515
|
-
assert.match(
|
|
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
|
|
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((
|
|
4613
|
-
assert.equal((
|
|
4614
|
-
assert.match(
|
|
4615
|
-
assert.match(
|
|
4616
|
-
assert.match(
|
|
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((
|
|
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((
|
|
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(
|
|
5886
|
-
assert.match(
|
|
5887
|
-
assert.match(
|
|
5888
|
-
assert.match(
|
|
5889
|
-
assert.match(
|
|
5890
|
-
assert.match(
|
|
5891
|
-
assert.match(
|
|
5892
|
-
assert.match(
|
|
5893
|
-
assert.match(
|
|
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(
|
|
5908
|
-
assert.match(
|
|
5909
|
-
assert.match(
|
|
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(
|
|
5912
|
-
assert.match(
|
|
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(
|
|
5915
|
-
assert.match(
|
|
5916
|
-
assert.match(
|
|
5917
|
-
assert.match(
|
|
5918
|
-
assert.match(
|
|
5919
|
-
assert.match(
|
|
5920
|
-
assert.match(
|
|
5921
|
-
assert.match(
|
|
5922
|
-
assert.match(
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 为空且日志有模型名 → 回填(只填空不覆盖)
|