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.
@@ -0,0 +1,92 @@
1
+ // dsh-agent-board — 弹层定位钳制纯函数单测(test/popover-clamp.test.mjs,task-mvt05utl)
2
+ // 背景:用户 2026-10-29 截图报「排版有点乱」①主修——设置弹层(PoolCfgPopover)fixed 定位统一右锚定
3
+ // (right=视口右缘-按钮右缘,内容向左延伸),按钮贴抽屉左缘(窄窗下工具栏折行,⚙/路由按钮落到第二行
4
+ // 左端)时弹层左半探出抽屉/视口被裁。修法=clampPopoverPos 纯函数统一预算锚点(两弹层共用):
5
+ // 左溢改左锚(左缘=抽屉左缘+8)/右溢钳右(右缘=抽屉右缘-8)/正常右锚不动;zoom 各项除档还原。
6
+ // client 无渲染 harness(react 不在包依赖,渲染由 dsh web 客户端运行时注入)——与历卡同一纪律:
7
+ // 纯函数走 new Function 提取直测(window 垫片取视口口径)+ 源码级接线断言 + client.js 产物标记。
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+
12
+ const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
13
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
14
+
15
+ // 纯函数提取(apply 片段不可 import,历卡同款手法):两工具相邻声明,提取窗到 ModelRoutePopover 为止
16
+ const fnSrc = dash.slice(dash.indexOf('function clampPopoverPos'), dash.indexOf('function ModelRoutePopover'))
17
+ assert.ok(fnSrc.indexOf('function popoverBounds') >= 0, '提取窗须同时含 popoverBounds(两工具相邻声明)')
18
+ assert.ok(fnSrc.indexOf('var POOL_POPOVER_W') >= 0 && fnSrc.indexOf('var ROUTE_POPOVER_W') >= 0, '提取窗须含预算宽常量')
19
+
20
+ // 浏览器全局垫片(clamp 内部读 window.innerWidth/innerHeight 取视口口径;零依赖不装 jsdom)
21
+ globalThis.window = { innerWidth: 1200, innerHeight: 800 }
22
+ const clamp = new Function(fnSrc + '\nreturn clampPopoverPos')()
23
+
24
+ // ===== 三态①:正常不动(宽抽屉、按钮居右 → 右锚定贴按钮右缘,与旧式逐字同值)=====
25
+ test('①正常:右锚定不动(top/right/maxH 与旧式同值),不误触发左锚', () => {
26
+ var out = clamp({ bottom: 100, right: 900 }, 360, 1, { left: 280, right: 1200 })
27
+ // 越界比较不触发:按钮右缘-预算宽 540 ≥ 抽屉左缘+8=288
28
+ assert.equal(out.left, null, '正常态不得改左锚')
29
+ assert.equal(out.right, Math.max(8, 1200 - 900) / 1, 'right=旧式 max(8, vw - r.right)')
30
+ assert.equal(out.top, (100 + 4) / 1, 'top=旧式 (r.bottom + 4)')
31
+ assert.equal(out.maxH, Math.max(140, (800 - 100 - 16) / 1), 'maxH=旧式公式')
32
+ assert.equal(out.right != null && out.left == null, true, '右锚式恰单锚')
33
+ })
34
+
35
+ // ===== 三态②:左溢改左锚(按钮贴抽屉左缘 → 左缘=抽屉左缘+8,修截图被裁病灶)=====
36
+ test('②左溢:按钮右缘-预算宽 < 抽屉左缘+8 → 改左锚定(left=抽屉左缘+8,right 收起不双锚拉宽)', () => {
37
+ var out = clamp({ bottom: 100, right: 340 }, 360, 1, { left: 280, right: 1200 })
38
+ // 340-360=-20 < 288 → 左溢成立;288+360=648 ≤ 1192 → 右不溢,保持左锚
39
+ assert.equal(out.left, 288, 'left=抽屉左缘+8(280+8)')
40
+ assert.equal(out.right, null, '左锚态 right 收起(双锚会拉宽弹层)')
41
+ assert.equal(out.top, 104, 'top 不受锚向影响')
42
+ assert.equal(out.maxH, 684, 'maxH 不受锚向影响')
43
+ })
44
+
45
+ // ===== 三态③:右溢钳右(左锚后右缘仍越界 → 钳回右锚、右缘=抽屉右缘-8 兜底)=====
46
+ test('③右溢:抽屉比弹层还窄 → 钳回右锚定(右缘=抽屉右缘-8)极端兜底', () => {
47
+ var out = clamp({ bottom: 100, right: 340 }, 360, 1, { left: 280, right: 400 })
48
+ // 340-360=-20 < 288 → 左溢成立;288+360=648 > 400-8=392 → 右溢 → 钳右
49
+ assert.equal(out.left, null, '右溢兜底回右锚')
50
+ assert.equal(out.right, Math.max(8, 1200 - 392) / 1, 'right 使弹层右缘=抽屉右缘-8(1200-808=392)')
51
+ assert.equal(1200 - out.right, 392, '可视右缘=抽屉右缘-8')
52
+ })
53
+
54
+ // ===== zoom≠1 坐标还原(/z 口径收进钳制函数,两锚向同款;×z 还原可视坐标)=====
55
+ test('④zoom:左锚 left 与右锚 right 各项除以档位还原,×z 复原可视坐标贴按钮/贴抽屉缘', () => {
56
+ var z = 1.5
57
+ var leftMode = clamp({ bottom: 100, right: 340 }, 360, z, { left: 280, right: 1200 })
58
+ assert.equal(leftMode.left, 288 / z, 'left 除以档位')
59
+ assert.equal(leftMode.left * z, 288, '×z 还原可视左缘=抽屉左缘+8')
60
+ assert.equal(leftMode.top, (100 + 4) / z, 'top 除以档位')
61
+ assert.equal(leftMode.right, null, 'zoom 下左锚态 right 仍收起')
62
+ var rightMode = clamp({ bottom: 100, right: 900 }, 360, z, { left: 280, right: 1200 })
63
+ assert.equal(rightMode.right, Math.max(8, 1200 - 900) / z, 'right 除以档位')
64
+ assert.equal(1200 - rightMode.right * z, 900, '×z 还原可视右缘=按钮右缘(不漂移)')
65
+ assert.equal(rightMode.maxH, Math.max(140, (800 - 100 - 16) / z), 'maxH 除以档位')
66
+ })
67
+
68
+ // ===== 接线断言(源码级):两弹层同款 clamp + 渲染条件双锚 + 抽屉边界实测 =====
69
+ test('⑤接线:两弹层 toggle 同款 clamp 调用 + 渲染条件双锚(left 非空走左锚)+ 旧式公式退役', () => {
70
+ assert.match(dash, /setPos\(clampPopoverPos\(r, ROUTE_POPOVER_W, z, popoverBounds\(btnRef\.current\)\)\)/, 'ModelRoutePopover 缺 clamp 接线')
71
+ assert.match(dash, /setPos\(clampPopoverPos\(r, POOL_POPOVER_W, z, popoverBounds\(btnRef\.current\)\)\)/, 'PoolCfgPopover 缺 clamp 接线')
72
+ // 渲染:left 非空 → left 锚定(React null style 省略),right 同理——两弹层渲染同款
73
+ var renders = dash.match(/left: pos\.left != null \? pos\.left \+ 'px' : null, right: pos\.right != null \? pos\.right \+ 'px' : null/g) || []
74
+ assert.ok(renders.length >= 2, '两弹层渲染须都接条件双锚(实测 ' + renders.length + ' 处)')
75
+ // 旧式未钳 setPos 公式须已退役(防双轨残留)
76
+ assert.ok(dash.indexOf('setPos({ top:') < 0, '旧式 setPos({ top: ... }) 须已退役')
77
+ // 抽屉边界实测:popoverBounds 沿按钮向上找首个 position:fixed 祖先(面板根),找不到退化视口
78
+ assert.match(dash, /getComputedStyle\(el\)\.position === 'fixed'/)
79
+ assert.match(dash, /return \{ left: 0, right: window\.innerWidth \}/, 'popoverBounds 缺视口退化兜底')
80
+ })
81
+
82
+ // ===== 池条折行(轻症顺带,task-mvt05utl ③)=====
83
+ test('⑥池条:PoolStatus 容器加 flexWrap(窄抽屉下 Worker/Verifier 计数与模型芯片折行不挤一排)', () => {
84
+ assert.match(dash, /fontSize: 9, color: C\.text2, display: 'inline-flex', gap: 4, alignItems: 'center', flexWrap: 'wrap'/, 'PoolStatus 容器缺 flexWrap')
85
+ })
86
+
87
+ // ===== 产物重组装 =====
88
+ test('⑦产物:clamp/池条标记逐一出现在 lib/client.js(需 npm run build-client 重组装)', () => {
89
+ for (const m of ['clampPopoverPos', 'popoverBounds', 'POOL_POPOVER_W', 'ROUTE_POPOVER_W']) {
90
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
91
+ }
92
+ })
@@ -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 为空且日志有模型名 → 回填(只填空不覆盖)
@@ -12,6 +12,10 @@
12
12
  // 用例 E 走 rpc 直调 harness(verify-timing.test.mjs 同款薄桩)。
13
13
  import { test } from 'node:test'
14
14
  import assert from 'node:assert/strict'
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import { readFileSync } from 'node:fs'
15
19
  import { createMockCtx } from './helpers/mock-ctx.mjs'
16
20
  import * as plugin from '../index.mjs'
17
21
  import { createRpc } from '../lib/rpc.mjs'
@@ -296,7 +300,7 @@ function mkGateTask(over) {
296
300
  return Object.assign({ id: 'g1', title: '续跑闸卡', 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', history: [], messages: [] }, over || {})
297
301
  }
298
302
  function mkGateBoard(tasks) { return { version: 12, ownerSession: GATE_SID, boardMode: 'auto', tasks: tasks || [] } }
299
- function mkGateDispatch(board, ctxOver, stateOver) {
303
+ function mkGateDispatch(board, ctxOver, stateOver, depsOver) {
300
304
  var listeners = []
301
305
  var timerCalls = []
302
306
  var notes = []
@@ -328,13 +332,13 @@ function mkGateDispatch(board, ctxOver, stateOver) {
328
332
  var dispatch = createDispatch(
329
333
  Object.assign(base, ctxOver || {}),
330
334
  state,
331
- {
335
+ Object.assign({
332
336
  rt: async function () { return board }, wt: async function () {}, mutateLocked: async function (sid, fn) { return fn(board) }, kickCycle: function () {},
333
337
  rootForSession: function () { return { id: GATE_SID } },
334
338
  withTimeout: function (p) { return p }, runsFor: function () { return runs }, feedbackOn: function () { return true },
335
339
  pushSysNote: function (s, text, taskId) { notes.push({ sid: s, text: text, taskId: taskId }) },
336
340
  maybeNotify: function () {}, notifyTaskDone: function () {},
337
- })
341
+ }, depsOver || {}))
338
342
  return { dispatch: dispatch, state: state, runs: runs, listeners: listeners, timerCalls: timerCalls, notes: notes, board: board }
339
343
  }
340
344
  var flushGate = async function () { for (var i = 0; i < 8; i++) await new Promise(function (r) { setImmediate(r) }) }
@@ -431,6 +435,40 @@ test('续跑健康闸③(task-mvowb2hc 洞④回归):健康模型续跑逐
431
435
  assert.equal(t.claimedBy, 'child-old', '认领位回原子会话')
432
436
  })
433
437
 
438
+ test('续跑健康闸④(task-mvd3li6 连败软闸):原模型 recentRuns ≥2 全败 → 放弃续跑改 fresh spawn(取模链软闸跳过病模型取次选)', async () => {
439
+ // 病模型在档位槽首位(真实形态:glm-5.2 在 laneModels.light.worker)——续跑闸拦截后 fresh spawn
440
+ // 经取模链软闸跳过病模型,槽内次选接手(完整修复链路:闸拦截 + 链跳过两级都在)
441
+ var t = mkGateTask({ size: 'light', runs: [gateResumeRow('prov/glm-5.3-flash')] })
442
+ var board = mkGateBoard([t])
443
+ board.laneModels = { light: { worker: ['prov/glm-5.3-flash', 'prov/alt-model'], verifier: [] }, standard: { worker: [], verifier: [] }, heavy: { worker: [], verifier: [] } }
444
+ var sent = []
445
+ var h = mkGateDispatch(board, {
446
+ subagents: {
447
+ list: function () { return ['mock'] },
448
+ getProvider: function () { return { inheritsParentContext: false } },
449
+ start: async function () { throw new Error('不该走一次性路径') },
450
+ startContinuable: async function () { return { childId: 'child-new', messageId: 'm1' } },
451
+ sendMessage: async function (p, cid, content, opts) { sent.push({ cid: cid, text: content[0].text }); return 'm2' },
452
+ },
453
+ })
454
+ // 病模型种法:聚合表 recentRuns ≥2 全败(settleRunRecord 每次结算喂给的形态,直种等效)
455
+ h.state.modelHealth[GATE_SID] = { 'prov/glm-5.3-flash': { state: 'unknown', observed: 'unknown', lastSeen: null, lastProbe: null, note: '测试种入', observedNote: '测试种入', recentRuns: [{ ok: false, at: '2026-01-01T00:00:00.000Z', latencyMs: null }, { ok: false, at: '2026-01-01T00:01:00.000Z', latencyMs: null }] } }
456
+ var origErr = console.error; console.error = function () {}
457
+ try {
458
+ await h.dispatch.poolCycle(GATE_SID)
459
+ await flushGate()
460
+ assert.deepEqual(sent, [], '连败软闸拦截:零续跑投递(不再空烧病模型)')
461
+ var note = (t.messages || []).find(function (m) { return m.kind === 'resume-fallback' })
462
+ assert.ok(note && /续跑放弃/.test(note.text), '续跑放弃留痕(resume-fallback 消息)')
463
+ assert.match(note.text, /prov\/glm-5\.3-flash/)
464
+ assert.equal(t.claimedBy, 'child-new', '回退 fresh spawn 落卡(新 Worker)')
465
+ var last = t.runs[t.runs.length - 1]
466
+ assert.equal(last.model, 'prov/alt-model', 'fresh spawn 走健康取模链:槽内软闸跳过病模型,次选接手')
467
+ assert.equal(last.modelSource, 'lane', 'modelSource=lane 留档')
468
+ assert.equal(Object.keys(h.state.badModels).length, 0, '软熔断不落 badModels 黑名单(与硬熔断语义分治)')
469
+ } finally { console.error = origErr }
470
+ })
471
+
434
472
  test('死会话哨兵(task-mvowb2hc 洞③):sendMessage 返回但会话不在活跃目录 → 立即失败重派不留 running 假相', async () => {
435
473
  var t = mkGateTask({ runs: [gateResumeRow('prov/glm-5.3-flash')] })
436
474
  var board = mkGateBoard([t])
@@ -508,3 +546,256 @@ test('死会话哨兵·退位(task-mvowb2hc 洞③):会话在活跃目录
508
546
  assert.ok(h2.runs['g2'], '活跃 rec 保留(结算归事件/超时通道)')
509
547
  assert.equal(t2.runs[1].outcome, 'running', '续跑留档不被误关')
510
548
  })
549
+
550
+ // ===== 挂死零产出检测(task-mvqcoz9l):在跑 worker/verifier run 日志字节零增长判死重派+喂熔断 =====
551
+ // 原形(2026-10-27 降级实测):glm-5.2 续跑挂「连接开着永不响应」——会话不死(死会话哨兵查不出)、
552
+ // 不报错(无 settle 事件)、零产出(日志零增长 7 分钟),running 假相直到人工终止。本套与死会话哨兵
553
+ // 同 harness(mkGateDispatch,depsOver 增 sessionsRoot 注入真日志文件):
554
+ // <root>/b1/<childId>/session.v4.jsonl.zstd(logSizeOf 按桶探测,与生产布局一致)。
555
+ function mkHungLogRoot(childId, bytes) {
556
+ var root = fs.mkdtempSync(path.join(os.tmpdir(), 'tbhung-'))
557
+ var dir = path.join(root, 'b1', childId)
558
+ fs.mkdirSync(dir, { recursive: true })
559
+ var logPath = path.join(dir, 'session.v4.jsonl.zstd')
560
+ fs.writeFileSync(logPath, Buffer.alloc(bytes || 256))
561
+ return { root: root, logPath: logPath }
562
+ }
563
+ // 挂死形态的活跃 rec(running + continuable worker)
564
+ function hungRec(taskId, childId, model) {
565
+ return { id: childId, childId: childId, continuable: true, ran: true, run: null, role: 'worker', taskId: taskId, startedAt: Date.now(), model: model, settled: false }
566
+ }
567
+ // 预置基线:lastLogBytes 与基线时间(601s 前)——测试里 poolCycle 是快轮、墙钟不推进,600s 墙钟地板
568
+ //(task-mvrt9tn5 定稿:固定值,不读健康表)需预置跨过
569
+ function seedBaseline(row, bytes) { row.lastLogBytes = bytes; row.zeroGrowthSince = Date.now() - 601000 }
570
+
571
+ test('挂死检测①(task-mvqcoz9l):running+日志零增长 40 轮 → 判挂死重派(settleRun 失败臂)+ 熔断连击+1 + 留档关账', async () => {
572
+ var L = mkHungLogRoot('child-old', 256)
573
+ try {
574
+ var t = mkGateTask({ status: 'in-progress', claimedBy: 'child-old', claimedAt: '2026-01-01T00:00:00Z', runs: [{ role: 'worker', id: 'child-old', at: '2026-01-01T00:00:00.000Z', model: 'prov/glm-5.2', outcome: 'running', hardMin: 120, continuable: true }] })
575
+ var board = mkGateBoard([t])
576
+ var sent = []
577
+ var h = mkGateDispatch(board, {
578
+ subagents: {
579
+ list: function () { return ['mock'] },
580
+ getProvider: function () { return { inheritsParentContext: false } },
581
+ start: async function () { throw new Error('挂死续跑命中时不该走一次性路径') },
582
+ startContinuable: async function () { throw new Error('挂死续跑命中时不该 fresh spawn') },
583
+ sendMessage: async function (p, cid, content, opts) { sent.push(cid); return 'm2' },
584
+ interrupt: function () {},
585
+ listChildren: async function () { return [{ id: 'child-old' }] },
586
+ },
587
+ }, null, { sessionsRoot: L.root })
588
+ h.runs['g1'] = hungRec('g1', 'child-old', 'prov/glm-5.2')
589
+ seedBaseline(t.runs[0], 256)
590
+ for (var i = 0; i < 41; i++) { await h.dispatch.poolCycle(GATE_SID); await flushGate() }
591
+ // 第 1-40 轮零增长计数到 40(基线已跨 600s 地板——本用例模型无延迟数据,固定阈值对无数据模型同值 600s),
592
+ // 第 40 轮判挂死;第 41 轮重派续跑
593
+ assert.equal(t.runs[0].outcome, 'timeout/error', '挂死 run 关账(settleRun 失败臂 err 路径 → timeout/error)')
594
+ assert.ok(t.runs[0].endedAt, '关账写 endedAt')
595
+ assert.equal(t.runs[0].lastLogBytes, 256, 'lastLogBytes 每轮写回(比对锚点)')
596
+ assert.match(String(t.lastError || ''), /挂死/, '挂死原因落卡(lastError)')
597
+ assert.match(String(t.lastError || ''), /超挂死墙钟阈值 600s/, '阈值 600s 直述(task-mvrt9tn5 断言③:固定值文案,不分态)')
598
+ assert.doesNotMatch(String(t.lastError || ''), /3×|实测延迟|地板值/, '假精确回归锁:无数据模型不再出现「3×0s」假精确 / 分态文案(干预定稿断言)')
599
+ assert.equal(t.retryCount, 1, '失败计数照旧(失败臂语义不变)')
600
+ assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/glm-5.2'], 1, '熔断连击+1(挂死=模型面故障)')
601
+ assert.ok(!h.state.badModels[GATE_SID + '|prov/glm-5.2'], '连击 1 < 3 不拉黑')
602
+ assert.equal(h.runs['g1'] && h.runs['g1'].resumed, true, '判死后卡重派(新 rec 接手)')
603
+ assert.deepEqual(sent, ['child-old'], '重派=续跑冷复活原子会话')
604
+ assert.equal(t.status, 'in-progress', '重派落卡 in-progress')
605
+ assert.equal(t.claimedBy, 'child-old', '认领位回原子会话')
606
+ assert.equal(t.runs[1].resume, true, '续跑留档照旧')
607
+ assert.equal(t.runs[1].outcome, 'running', '续跑留档挂 running')
608
+ assert.equal(t.runs[1].lastLogBytes, 256, '续跑行下轮首见重记基线(fresh 取自当前日志字节数,非复制旧行)')
609
+ assert.equal(t.runs[1].zeroGrowthRounds, undefined, '续跑行零增长连击从零起算(不继承上一轮连击)')
610
+ } finally { fs.rmSync(L.root, { recursive: true, force: true }) }
611
+ })
612
+
613
+ test('挂死检测②:增长中 run 不误伤——活体增长清零计数刷新基线;写风暴快轮被 600s 墙钟地板兜住不秒判', async () => {
614
+ var L = mkHungLogRoot('child-old', 256)
615
+ try {
616
+ var t = mkGateTask({ status: 'in-progress', claimedBy: 'child-old', claimedAt: '2026-01-01T00:00:00Z', runs: [{ role: 'worker', id: 'child-old', at: '2026-01-01T00:00:00.000Z', model: 'prov/glm-5.2', outcome: 'running', hardMin: 120, continuable: true }] })
617
+ var h = mkGateDispatch(mkGateBoard([t]), {
618
+ subagents: {
619
+ list: function () { return ['mock'] },
620
+ getProvider: function () { return { inheritsParentContext: false } },
621
+ start: async function () { throw new Error('不该走一次性路径') },
622
+ startContinuable: async function () { throw new Error('不该 fresh spawn') },
623
+ sendMessage: async function () { return 'm2' },
624
+ interrupt: function () {},
625
+ listChildren: async function () { return [{ id: 'child-old' }] },
626
+ },
627
+ }, null, { sessionsRoot: L.root })
628
+ h.runs['g1'] = hungRec('g1', 'child-old', 'prov/glm-5.2')
629
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
630
+ assert.equal(t.runs[0].lastLogBytes, 256, '首见只记基线')
631
+ assert.equal(t.runs[0].zeroGrowthRounds, undefined, '首见不计数(绝不在第一眼判死)')
632
+ // 快轮零增长 40 轮(墙钟不推进)→ 连击按轮累计,但 600s 墙钟地板兜住不判死
633
+ for (var i = 0; i < 40; i++) { await h.dispatch.poolCycle(GATE_SID); await flushGate() }
634
+ assert.equal(t.runs[0].zeroGrowthRounds, 40, '零增长连击按轮累计(每轮写回)')
635
+ assert.equal(t.runs[0].outcome, 'running', '600s 墙钟地板未过 → 不判死(写风暴下纯轮数会秒判刚 spawn 的 run)')
636
+ assert.equal(t.status, 'in-progress', '不误伤')
637
+ assert.ok(h.runs['g1'], '活跃 rec 保留')
638
+ // 活体增长 → 清零计数 + 刷新基线
639
+ fs.appendFileSync(L.logPath, Buffer.alloc(64))
640
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
641
+ assert.equal(t.runs[0].lastLogBytes, 320, '基线刷新到最新字节数')
642
+ assert.equal(t.runs[0].zeroGrowthRounds, undefined, '活体增长即清零计数')
643
+ // 清零后再零增长 3 轮 → 仍不判死(3 < 40 轮)
644
+ for (var i = 0; i < 3; i++) { await h.dispatch.poolCycle(GATE_SID); await flushGate() }
645
+ assert.equal(t.runs[0].zeroGrowthRounds, 3, '清零后重新计数')
646
+ assert.equal(t.runs[0].outcome, 'running', '3 轮 < 40 不判死')
647
+ assert.equal(t.status, 'in-progress', '全程零误伤')
648
+ assert.ok(!h.state.modelFailStreak || !Object.keys(h.state.modelFailStreak).length, '零熔断连击')
649
+ } finally { fs.rmSync(L.root, { recursive: true, force: true }) }
650
+ })
651
+
652
+ test('挂死检测③(源码级):40 轮阈值常量化 + 600s 墙钟地板常量(task-mvrt9tn5 定稿:延迟缩放删除)+ poolCycle 挂点 + 熔断连击接线 + 边界', () => {
653
+ var dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
654
+ // 拆分第 1 刀(task-mvt8c1yx,仲裁扩权放行):挂死域(常量/isHungKillFailure/帧活性/hungZeroOutputSweep)
655
+ // 已拆至 lib/hung.mjs(叶子模块)——机制锁断言随源码重指 hung 源;调用点/白名单接线断言仍锁 dispatch 源。
656
+ // 拆分第 3 刀(task-mvt8c1yx-3,仲裁扩权放行):isModelKillFailure 随熔断连击拆至 lib/model-health.mjs
657
+ // ——isModelKillFailure 白名单机制锁断言随源码重指 model-health 源;settleWorker 调用点接线断言仍锁 dispatch 源。
658
+ // 拆分第 6 刀(task-mvt8c1yx-6,仲裁扩权放行):settleWorker 失败臂随角色结算搬至 lib/settle.mjs
659
+ // ——挂死熔断文案分态断言随源码重指 settle 源(仲裁放行 test 文件)。
660
+ var hung = readFileSync(new URL('../lib/hung.mjs', import.meta.url), 'utf8')
661
+ var mh = readFileSync(new URL('../lib/model-health.mjs', import.meta.url), 'utf8')
662
+ var stl = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
663
+ assert.match(hung, /var HUNG_ZERO_GROWTH_ROUNDS = 40/, 'N 轮阈值常量化:连续 40 轮零增长判挂死(600s/15s 心跳换算,干预定稿锁死值)')
664
+ assert.match(hung, /var HUNG_WALL_FLOOR_MS = 600000/, '墙钟地板 600s 常量化(task-mvrt9tn5:三连误杀实证定稿,对齐最长正常静默)')
665
+ assert.doesNotMatch(dsp, /HUNG_WALL_LATENCY_MULT|function hungWallOf/, '延迟缩放已删(3×latencyMs 方向作废:延迟与长生成静默无关)')
666
+ assert.doesNotMatch(dsp, /超自适应墙钟阈值/, '判死文案不再引用自适应阈值')
667
+ assert.match(hung, /async function hungZeroOutputSweep\(sid, runs, snap\) \{/, '挂死零产出检测函数')
668
+ assert.match(dsp, /hungN = await hung\.hungZeroOutputSweep\(sid, runs, snap\)/, 'poolCycleBody 挂点(幽灵回收之后、活跃计数之前)')
669
+ assert.match(hung, /lastLogBytes/, 'run 留档基线字段(每轮写回)')
670
+ assert.match(hung, /zeroGrowthRounds/, '零增长连击字段(每轮写回)')
671
+ assert.match(mh, /if \(isHungKillFailure\(s\)\) return true/, '挂死前缀进 isModelKillFailure 白名单 = 计入熔断连击')
672
+ assert.match(stl, /isHungKillFailure\(errText\) \? HUNG_CIRCUIT_WHY : undefined/, 'settleWorker 失败臂挂死熔断文案分态(拆分第 6 刀随源码重指 settle 源)')
673
+ assert.match(hung, /rec\.role === 'worker' \|\| rec\.role === 'verifier'/, '边界:只盖 worker/verifier 在跑 rec(decomposer/hook 不盖)')
674
+ assert.match(hung, /Date\.now\(\) - since >= HUNG_WALL_FLOOR_MS/, '判死双闸:40 轮 × 600s 墙钟地板(固定常量直比)')
675
+ assert.match(hung, /超挂死墙钟阈值 ' \+ wallTxt/, '判死文案带出实际生效阈值(固定值直述,排查时一眼分清误杀 vs 真挂死)')
676
+ })
677
+
678
+ test('挂死检测④:挂死三连击 → badModels 熔断+健康表 circuit+系统通知(挂死分态文案);worker 三连败阻塞语义照旧适用(挂死=失败)', async () => {
679
+ var L = mkHungLogRoot('child-old', 256)
680
+ try {
681
+ var t = mkGateTask({ status: 'in-progress', claimedBy: 'child-old', claimedAt: '2026-01-01T00:00:00Z', runs: [{ role: 'worker', id: 'child-old', at: '2026-01-01T00:00:00.000Z', model: 'prov/glm-5.2', outcome: 'running', hardMin: 120, continuable: true }] })
682
+ var board = mkGateBoard([t])
683
+ board.workerModel = 'prov/glm-5.2' // 连败软闸拦截后的 fresh spawn 带板级模型,连击可继续累计到 3
684
+ var sent = []
685
+ var h = mkGateDispatch(board, {
686
+ subagents: {
687
+ list: function () { return ['mock'] },
688
+ getProvider: function () { return { inheritsParentContext: false } },
689
+ start: async function () { throw new Error('不该走一次性路径') },
690
+ // task-mvd3li6 连败软闸(healthyModel 增读 recentRuns≥2 全败)后:挂死 #2 的重派续跑被
691
+ // 续跑健康闸拦截改 fresh spawn(这正是「报错一次+挂死一次→第二次派发跳过病模型」的机制),
692
+ // fresh spawn 走 startContinuable——mock 放行(仲裁裁决 A,2026-10-27)
693
+ startContinuable: async function () { return { childId: 'child-new', messageId: 'm1' } },
694
+ sendMessage: async function (p, cid) { sent.push(cid); return 'm2' },
695
+ interrupt: function () {},
696
+ listChildren: async function () { return [{ id: 'child-old' }] },
697
+ },
698
+ }, null, { sessionsRoot: L.root })
699
+ h.runs['g1'] = hungRec('g1', 'child-old', 'prov/glm-5.2')
700
+ seedBaseline(t.runs[0], 256)
701
+ var round = async function () { await h.dispatch.poolCycle(GATE_SID); await flushGate() }
702
+ // 挂死 #1(第 1-40 轮,连击 1)→ 重派续跑(健康闸放行:1 次失败 < 2 未达软闸线)→ 预置新行基线已跨 600s 地板 →
703
+ // 挂死 #2(连击 2)→ 续跑健康闸拦截(连败软闸,recentRuns 2 全败)改 fresh spawn(板级模型接手)→ 挂死 #3(连击 3 → 熔断)
704
+ for (var i = 0; i < 40; i++) await round()
705
+ assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/glm-5.2'], 1, '挂死 #1 连击 1')
706
+ await round(); seedBaseline(t.runs[t.runs.length - 1], 256)
707
+ for (var i = 0; i < 40; i++) await round()
708
+ assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/glm-5.2'], 2, '挂死 #2 连击 2')
709
+ await round(); seedBaseline(t.runs[t.runs.length - 1], 256)
710
+ for (var i = 0; i < 40; i++) await round()
711
+ assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/glm-5.2'], 3, '挂死 #3 连击 3')
712
+ assert.equal(h.state.badModels[GATE_SID + '|prov/glm-5.2'], true, '三连击 → badModels 熔断')
713
+ assert.equal(h.state.modelHealth[GATE_SID]['prov/glm-5.2'].state, 'circuit', '健康表 circuit 覆盖态')
714
+ var circNotes = h.notes.filter(function (n) { return /挂死零产出/.test(n.text) })
715
+ assert.ok(circNotes.length >= 1, '系统通知带挂死分态文案(非秒拆文案)')
716
+ assert.match(circNotes[circNotes.length - 1].text, /prov\/glm-5\.2/)
717
+ assert.match(circNotes[circNotes.length - 1].text, /后续取模链跳过该模型/)
718
+ // worker 三连败阻塞语义照旧适用(挂死=失败):retryCount 3 → blocked,不再自动重派
719
+ //(熔断后恢复走续跑健康闸 → fresh spawn 健康取模链,该链已有「续跑健康闸①」用例覆盖)
720
+ assert.equal(t.status, 'blocked', 'worker 失败 x3 → blocked(挂死按失败计数,三连败阻塞语义不变)')
721
+ assert.equal(t.retryCount, 3, '三次挂死各计一次失败')
722
+ // task-mvd3li6 连败软闸后:挂死 #1 重派走续跑(1<2 未达软闸线),挂死 #2 起软闸拦截改 fresh spawn,
723
+ // blocked 后零投递——sendMessage 只发生 1 次(核心语义不变:三连击→熔断→blocked 全保留)
724
+ assert.equal(sent.length, 1, '首连重派走 sendMessage 续跑;二连起连败软闸拦截改 fresh spawn,blocked 后零投递')
725
+ } finally { fs.rmSync(L.root, { recursive: true, force: true }) }
726
+ })
727
+
728
+ test('挂死检测⑤:verifier 在跑 run 同样盖到——判挂死走 settleVerifier 失败臂(verifyRetries 重排 + 既有即时熔断语义)', async () => {
729
+ var L = mkHungLogRoot('ver-old', 256)
730
+ try {
731
+ var t = mkGateTask({ status: 'verifying', verifierRun: 'ver-old', verifierRunAt: '2026-01-01T00:00:00Z', runs: [{ role: 'verifier', id: 'ver-old', at: '2026-01-01T00:00:00.000Z', model: 'prov/vm', outcome: 'running', hardMin: 120 }] })
732
+ var h = mkGateDispatch(mkGateBoard([t]), {
733
+ subagents: {
734
+ list: function () { return ['mock'] },
735
+ getProvider: function () { return { inheritsParentContext: false } },
736
+ start: async function () { return { id: 'run-x', result: new Promise(function () {}), dispose: async function () {} } },
737
+ startContinuable: async function () { throw new Error('verifier 恒走一次性面') },
738
+ sendMessage: async function () { return 'm2' },
739
+ interrupt: function () {},
740
+ listChildren: async function () { return [{ id: 'ver-old' }] },
741
+ },
742
+ }, null, { sessionsRoot: L.root })
743
+ // verifier rec 形态与生产一致(一次性面:无 childId,run.id 即子会话 id)
744
+ h.runs['g1'] = { id: 'ver-old', run: { id: 'ver-old', dispose: async function () {} }, role: 'verifier', taskId: 'g1', startedAt: Date.now(), model: 'prov/vm', settled: false }
745
+ seedBaseline(t.runs[0], 256)
746
+ for (var i = 0; i < 40; i++) { await h.dispatch.poolCycle(GATE_SID); await flushGate() }
747
+ assert.equal(t.runs[0].outcome, 'timeout/error', '挂死 verifier 关账(失败臂)')
748
+ assert.equal(t.verifyRetries, 1, 'verifier 失败臂计数(重新排队审查)')
749
+ assert.equal(t.status, 'verifying', '卡仍 verifying(重新排队)')
750
+ assert.match(String(t.lastError || ''), /挂死/, '挂死原因落卡')
751
+ assert.equal(h.state.badModels[GATE_SID + '|prov/vm'], true, 'verifier 失败即时熔断(既有 verifier 语义,非连击)')
752
+ var cur = h.runs['g1']
753
+ assert.ok(!cur || String(cur.id) !== 'ver-old', '原挂死 verifier rec 已摘除(busyTaskIds 释放,新 verifier 当轮接手)')
754
+ } finally { fs.rmSync(L.root, { recursive: true, force: true }) }
755
+ })
756
+
757
+ // ===== 挂死阈值定稿(task-mvrt9tn5 二度校准):固定 600s,延迟缩放作废 =====
758
+ // 三轮生产误杀实证(2026-10-28):慢模型长生成段 180s+ 零日志被杀;kimi-k3(健康表实测延迟 2s 的
759
+ // 快模型)正常干活 10 分钟同样被 180s 闸(=3×2s 地板)误杀——延迟(首 token 速度)与「长生成
760
+ // 静默时长」无关。断言(干预定稿):
761
+ // ① 600s 内静默不判(含 2s 延迟快模型形态——延迟数据在表也不缩放阈值);
762
+ // ② 超 600s 真挂死照判,文案「阈值 600s」直述,无「3×」假精确。
763
+ test('挂死检测⑥(task-mvrt9tn5):2s 延迟快模型 600s 内静默不判(延迟不缩放阈值);超 600s 照判且文案「阈值 600s」直述', async () => {
764
+ var L = mkHungLogRoot('child-slow', 256)
765
+ try {
766
+ var t = mkGateTask({ status: 'in-progress', claimedBy: 'child-slow', claimedAt: '2026-01-01T00:00:00Z', runs: [{ role: 'worker', id: 'child-slow', at: '2026-01-01T00:00:00.000Z', model: 'prov/kimi-k3', outcome: 'running', hardMin: 120, continuable: true }] })
767
+ var board = mkGateBoard([t])
768
+ var sent = []
769
+ var h = mkGateDispatch(board, {
770
+ subagents: {
771
+ list: function () { return ['mock'] },
772
+ getProvider: function () { return { inheritsParentContext: false } },
773
+ start: async function () { throw new Error('不该走一次性路径') },
774
+ startContinuable: async function () { throw new Error('此处不该 fresh spawn') },
775
+ sendMessage: async function (p, cid) { sent.push(cid); return 'm2' },
776
+ interrupt: function () {},
777
+ listChildren: async function () { return [{ id: 'child-slow' }] },
778
+ },
779
+ }, null, { sessionsRoot: L.root })
780
+ h.runs['g1'] = hungRec('g1', 'child-slow', 'prov/kimi-k3')
781
+ // 预置健康表:该模型最近观测延迟 2s(kimi-k3 健康表实测形态)——定稿后阈值固定 600s,延迟数据不再参与缩放
782
+ h.state.modelHealth[GATE_SID] = h.state.modelHealth[GATE_SID] || {}
783
+ h.state.modelHealth[GATE_SID]['prov/kimi-k3'] = { state: 'healthy', observed: 'healthy', lastSeen: new Date().toISOString(), latencyMs: 2000, note: '最近 run 成功', observedNote: '最近 run 成功', recentRuns: [{ ok: true, at: new Date().toISOString(), latencyMs: 2000 }] }
784
+ // ---- 阶段 A(不误杀反例):基线跨 599s(< 600s 阈值)+ 40 轮零增长 → 不判死(旧 3×latencyMs=6s / 180s 地板在此必误杀)----
785
+ seedBaseline(t.runs[0], 256)
786
+ t.runs[0].zeroGrowthSince = Date.now() - 599000 // 压掉 seedBaseline 的 601s,只跨 599s
787
+ for (var i = 0; i < 40; i++) { await h.dispatch.poolCycle(GATE_SID); await flushGate() }
788
+ assert.equal(t.runs[0].zeroGrowthRounds, 40, '轮数半边达线(40 轮零增长——轮数闸语义不变)')
789
+ assert.equal(t.runs[0].outcome, 'running', '599s < 600s 阈值 → 不判死(2s 延迟模型正常干活的 3 分钟+ 静默窗口不被误杀)')
790
+ assert.equal(t.status, 'in-progress', '快模型不误杀(task-mvrt9tn5 断言①:延迟数据在表也不缩放阈值)')
791
+ assert.ok(!h.state.modelFailStreak || !h.state.modelFailStreak[GATE_SID + '|prov/kimi-k3'], '零误杀 → 零熔断连击')
792
+ assert.equal(sent.length, 0, '未判死 → 无重派投递')
793
+ // ---- 阶段 B(真挂死照判):基线倒推 601s(> 600s 阈值)→ 下一轮判死,文案「阈值 600s」直述 ----
794
+ t.runs[0].zeroGrowthSince = Date.now() - 601000
795
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
796
+ assert.equal(t.runs[0].outcome, 'timeout/error', '超阈值真挂死照判(task-mvrt9tn5 断言②)——关账走 settleRun 失败臂')
797
+ assert.match(String(t.lastError || ''), /超挂死墙钟阈值 600s/, '判死文案固定值直述(600s,不引用延迟)')
798
+ assert.doesNotMatch(String(t.lastError || ''), /3×|实测延迟|地板值/, '假精确回归锁:不再出现「3×Ns 实测延迟」/分态文案')
799
+ assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/kimi-k3'], 1, '真挂死照喂熔断连击(挂死=模型面故障语义不变)')
800
+ } finally { fs.rmSync(L.root, { recursive: true, force: true }) }
801
+ })
@@ -6,7 +6,7 @@
6
6
  // ① 状态与持久化——state.uiZoom(localStorage 键 dsh-agent-board-ui-zoom,白名单五档,脏值/异常回退 1)
7
7
  // ② 根容器接线——TopPanel 顶层 div style 挂 zoom: uiZoom(订阅值,notify 即时生效)
8
8
  // ③ 设置弹层档位——⚙ 弹层「界面」小节 ZoomCfg 下拉:90/100/110/125/150%,选中即 setItem+state+notify
9
- // ④ 弹层坐标补偿——zoom 子树内 fixed 弹层 top/right/maxH 除以档位还原 CSS px(仍贴按钮、不溢出视口)
9
+ // ④ 弹层坐标补偿——/z 口径收进 clampPopoverPos 统一锚点(左溢改左锚/右溢钳右),zoom=1 时右锚式与旧行为同值
10
10
  // ⑤ README 双份——根/包逐字一致 + 看板 UI 区补「界面缩放」说明行
11
11
  // ⑥ 产物重组装——关键标记逐一出现在 lib/client.js(需 npm run build-client)
12
12
  import { test } from 'node:test'
@@ -50,10 +50,15 @@ test('③档位:ZoomCfg 下拉五档 + 选中即 localStorage.setItem + state.
50
50
  assert.ok(dash.indexOf('界面缩放') >= 0, '弹层缺「界面缩放」标签')
51
51
  })
52
52
 
53
- // ===== ④ 弹层坐标补偿(zoom 子树内 fixed 弹层的 top/right/maxH 还原)=====
54
- test('④补偿:⚙ 弹层定位除以缩放档位(getBoundingClientRect 可视坐标 → CSS px),zoom=1 时与旧行为一致', () => {
53
+ // ===== ④ 弹层坐标补偿 + 钳制接线(task-mvt05utl:/z 口径收进 clampPopoverPos 统一锚点)=====
54
+ test('④补偿:两弹层 toggle 走 clampPopoverPos(/z 还原在钳制函数内统一,左溢改左锚),zoom=1 时右锚式与旧行为同值', () => {
55
55
  assert.match(dash, /var z = state\.uiZoom \|\| 1/)
56
- assert.match(dash, /setPos\(\{ top: \(r\.bottom \+ 4\) \/ z, right: Math\.max\(8, window\.innerWidth - r\.right\) \/ z, maxH: Math\.max\(140, \(window\.innerHeight - r\.bottom - 16\) \/ z\) \}\)/)
56
+ // 锚点统一(task-mvt05utl):两弹层同款 clamp 调用(预算宽常量 + popoverBounds 实测抽屉边界)
57
+ assert.match(dash, /setPos\(clampPopoverPos\(r, ROUTE_POPOVER_W, z, popoverBounds\(btnRef\.current\)\)\)/)
58
+ assert.match(dash, /setPos\(clampPopoverPos\(r, POOL_POPOVER_W, z, popoverBounds\(btnRef\.current\)\)\)/)
59
+ // /z 口径仍在(收进钳制函数):top 与 right 各项除以档位还原 CSS px
60
+ assert.match(dash, /var top = \(r\.bottom \+ 4\) \/ zz/)
61
+ assert.match(dash, /var right = Math\.max\(m, window\.innerWidth - r\.right\) \/ zz/)
57
62
  // maxHeight 改读补偿后的 pos.maxH(不再用未补偿的 calc(100vh - ...))
58
63
  assert.match(dash, /maxHeight: \(pos\.maxH \|\| 300\) \+ 'px'/)
59
64
  })