dsh-agent-board 1.9.3 → 1.9.4

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 CHANGED
@@ -41,7 +41,7 @@
41
41
  | **0.1.7 ~ 0.1.7-x**(含 rc) | **≥ 1.2.2(必须)** | 0.1.7 会话日志升级为 format v4:插件消息 `source.kind` 必须是生产者自有 kind。1.2.1 及更早会在**任务完成/阻塞回执**落盘时抛 `SessionFormatError: format v4 message requires a producer-owned source kind`,并连带使主窗口当前轮次失败(表现为「本轮运行失败」);同时「跳转会话」因宿主移除 `sessions.open` 而失效(控制台 `sessionsSvc.open is not a function`),卡片活动心跳读不到 v4 日志(`session.v4.jsonl.zstd`) |
42
42
  | 0.1.5-rc.1 ~ 0.1.6 | ≤ 1.2.1 | 1.2.2 起按 0.1.7 协议编写(v4 source kind、`uiWorkspace.openSession` 跳转),旧宿主未做回归验证,建议停留 1.2.1 |
43
43
 
44
- > 当前版本 **v1.9.3** 已在宿主 **0.2.1-rc.5** 全量实测:739 断言全绿 + 拆解官/力度路由/健康嗅探/帧活性挂死检测真机 E2E 全链。
44
+ > 当前版本 **v1.9.4** 已在宿主 **0.2.1-rc.5** 全量实测:746 断言全绿 + 拆解官/力度路由/健康嗅探/帧活性挂死检测真机 E2E 全链。
45
45
 
46
46
  ```sh
47
47
  dsh plugin --profile web add dsh-agent-board@latest # 0.1.7+/0.2.x 宿主(推荐)
@@ -258,6 +258,7 @@ draft → pending → in-progress → verifying → resolved → archived
258
258
  - **重派冷复活续跑**:命中「上次 continuable Worker 结局=超时/失败」的待办卡时,不 spawn 新会话,改 `subagents.sendMessage(活父 Agent, childId, 断点续跑指令)`——子会话带着上一轮全部上下文复活,先盘点工作树再从断点继续;`t.runs[]` 追加一条 `resume: true` 记录(详情页历史会话按钮带 **↻** 标记),续跑同样计入三连败计数;续跑不可用(`NOT_RESUMABLE` 等)→ 回落全新 Worker,并把原因写进任务消息随首条 prompt 注入
259
259
  - **续跑基线(不拿旧文本冒充交付物)**:续跑轮结算只认**基线字节之后**新写的助手文本——一个字没产出就走「空文本按失败」重排,绝不把上一轮(被中断那次)的残留文本当成本轮交付物推进验收
260
260
  - **重启 reconcile(找回活跃续跑 Worker)**:host 重启会清空内存里的活跃 run 表,但持久子会话还活着。首轮派发周期对「进行中且无活跃 run」的卡查一次 `listChildren(root)`:仍在列 → 重建 rec 观测(监听器本就在)并重挂两级超时臂、基线取当前日志字节数;不在列 → 视为会话已死,走硬超时等价物(回待办重排 + 留一行流转记录),不占 Worker 并发位
261
+ - **野跑看门狗(wall-clock 兜底,v1.9.4)**:两级超时是内存 setTimeout 臂——**host 重启/插件热重载即清零**,而 reconcile 只重建可续跑 Worker 的臂,**一次性 run(Verifier/hook)的臂永不恢复** → 硬超时彻底失效、run 可无限野跑(实证:本体板一条 verifier run 跑 8 小时 2 分仍在活跃写盘,且因日志持续增长零增长挂死闸也抓不到)。修法=不依赖内存计时器:每轮派发周期扫**所有非落定卡**上的 unning 且无 endedAt 留档,年龄超该留档 hardMin(缺省 360 分钟,留档自带值优先)→ 按失败结算收口(野跑文案 + 清占位 + 喂熔断连击 + 重派),与「落定卡无 endedAt 留档 → stale-closed 关账」的僵尸清扫分工不重叠
261
262
  - **usage 增量计账(按 seq 水位线)**:注意「一个持久子会话被结算多次」是新形态——整份日志全量累加会把前几轮的 token 反复记账(实测同一 childId 结算两次=双倍)。现按 v4 日志事件自带的 `seq` 记水位线(落在 `t.runs[].usageSeq`):每次结算只累加水位线之后的 `assistant/message` 增量,本轮没新增量就一行都不记;one-shot 路径(每 run 独立日志)行为逐字不变
262
263
  - **开关 `workerContinuable`(默认开)**:关掉即逐字回退旧的一次性路径(零 `startContinuable`/零 `sendMessage`/零 `interrupt`,结算仍走 `run.result`+`dispose`);Verifier 与 hooks(`hook-pre`/`hook-post`)**保持一次性**,不受该开关影响
263
264
  - **结算双通道 + 池韧性(2026-10-06 事故修复)**:continuable 结算有事件通道(`agent/status` 的 running→idle,身份取 `agent.session.id`)与上报通道(`board_report` 落定即收尾)两条入口,任一到达即关账(结局/usage/超时臂三件套,幂等);派发周期自带**幽灵活跃表项 GC**(卡面证据核对回收残留 rec,防残留把派发容量顶到 0 拖死全池)+ 整轮 try/catch 与逐卡隔离(单点异常只作废该卡该轮)+ 去抖 latch 时间戳兜底复位
package/lib/dispatch.mjs CHANGED
@@ -1008,6 +1008,14 @@ export function createDispatch(ctx, state, deps) {
1008
1008
  // 落定卡的僵尸留档无活跃 rec,reconcile(只在飞卡)/reapGhostRecs(只清内存表项)都管不到它,
1009
1009
  // 而它若留在 running 会卡死 archive;安静板(只有已落定卡)会走空闲快进早退,清扫必须排在其前才能兜到。
1010
1010
  try { await settle.sweepZombieRuns(sid, snap) } catch (e) { console.error('[task-board] 僵尸留档清扫失败:', String(e)) }
1011
+ // 野跑看门狗(task-mvyzshi7):非落定卡 running 留档超 hardMin 墙钟即结算——重启把 armTimeouts 两级
1012
+ // 超时臂(内存 setTimeout)清零后,一次性 run(verifier 等)的超时臂永不恢复,run 无上限可无限野跑
1013
+ // (本体板实证 8h)。位置在 sweepZombieRuns(落定卡辖区)之后、幽灵回收之前:结算会把超龄卡翻回
1014
+ // pending/重排审查,本轮持锁段的 pickDispatch 读的是锁内新鲜 d,当轮即可重派;重读快照同回收纪律
1015
+ //(结算写盘后 stalePool/staleInfo 判定不能再拿旧 snap 误判)。
1016
+ var wildN = 0
1017
+ try { wildN = await settle.sweepWildRuns(sid, snap) } catch (e) { console.error('[task-board] 野跑看门狗失败(本轮跳过,派发照常):', String(e)) }
1018
+ if (wildN > 0) { try { snap = await rt(sid) } catch (_) {} }
1011
1019
  // 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
1012
1020
  // 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
1013
1021
  // 故有回收时重读一次快照——否则下面空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(写盘已归一进锁)。
package/lib/settle.mjs CHANGED
@@ -867,6 +867,140 @@ export function createSettle(opts) {
867
867
  return swept.length
868
868
  }
869
869
 
870
+ // ===== 野跑看门狗(task-mvyzshi7):非落定卡 running 留档超 hardMin 墙钟即结算 =====
871
+ // 事故(本体板 2026-11-02 实证,task-mvyyxs5u):host 重启把 armTimeouts 的两级超时臂(内存 setTimeout)
872
+ // 清零,而 reconcileRoot 只重建 continuable Worker 的观测与超时臂(三道门禁自证「只对 role=worker +
873
+ // continuable」)——一次性 verifier run 的超时臂**永不恢复**,hardMin 硬超时彻底失效,run 无任何上限
874
+ // 可无限野跑(实证:verifier run 跑 8 小时 2 分仍在活跃写盘;日志持续增长,600s 零增长挂死闸也抓不到)。
875
+ // 修法(墙钟兜底,不依赖内存计时器):每轮 poolCycle 扫所有**非落定卡**上 outcome==='running' 且无
876
+ // endedAt 的留档,年龄 now-at 超过该留档的 hardMin(缺省 360 分钟,留档自带值优先)→ 按失败结算收口:
877
+ // · 关账:outcome='timeout/error' + endedAt + note(settleRun 超时臂同款结局,字段与 closeRunHistory
878
+ // 一致、并在同一持锁事务里连同卡面推进一次写完);
879
+ // · 落卡:lastError 野跑文案 + 喂熔断连击(feedModelFailure 三连拉黑——与在飞臂的 verifier 即时
880
+ // badModels 熔断不同:重启残影不欺负模型,走连击更保守);
881
+ // · worker(认领位曾指向它):settleWorker 失败臂同款——retryCount++ → pending 重排 / x3 blocked;
882
+ // · verifier(幂等位曾指向它):settleVerifier 失败臂同款——verifyRetries++ → 重排审查 / x3 转人工;
883
+ // · hook-post:幂等占用位已清 → 重挂 hooks.post.pending 待跑标记(releaseSpawnPlaceholder 同款,
884
+ // 下轮 cycle 重新 spawn 收口);hook-pre 的状态机由 poolCycle 既有 2min 死占位回收自愈,不动;
885
+ // · 其余(占位未指向本档/卡不在对应跑态/escalation/frozen):只关账+留痕,绝不碰卡面状态——
886
+ // 占位易主说明卡面已有新 run 接手(孤儿回收后重派的形状),清卡面会误杀新 run;
887
+ // · 补停:锁外对野跑子会话补一枪 interrupt(stopSettledOneShot 同款 authority 形状)——不补停则旧
888
+ // 会话残影继续空烧(事故原形 8h);continuable 补停失败 → noResume 落档(重派回退 fresh spawn)。
889
+ // 保守双闸(防误杀在飞 run):留档有活跃 rec 接管且 rec 年龄未超线 → 跳过——rec 年轻(reconcile 重建/
890
+ // 续跑刚接手)说明超时臂在位、run 有主;留档 at 是重启前的时间戳、年龄大不算野跑。rec 年龄也超线才动手
891
+ // (settleRun 有 settled 守卫,双闸再防一层「墙钟 vs 在飞臂」的双结算重派)。
892
+ // 与 sweepZombieRuns 分工:那个管**落定卡**(resolved/cancelled/archived)的 running 无 endedAt 留档
893
+ // 关账(stale-closed,无年龄闸);本函数管**非落定卡**的超龄 running 留档真超时结算——状态域互斥不重叠。
894
+ // 边界(只加墙钟兜底):armTimeouts 两级超时臂语义、reconcileRoot 门禁、sweepZombieRuns 口径均不动。
895
+ var WILD_ERR = 'run 超出硬超时上限(重启后超时臂丢失的野跑),按模型面故障结算重派'
896
+ var WILD_RUN_NOTE = '野跑看门狗墙钟结算(重启后超时臂丢失)'
897
+ var WILD_CIRCUIT_WHY = '野跑超硬超时墙钟(重启后超时臂丢失),已熔断'
898
+ function wildHardMinOf(r) { var hm = Number(r && r.hardMin); return hm > 0 ? hm : 360 }
899
+ // 候选闸(预扫/锁内共用同一判定,两处口径永不漂移):非落定卡 + running 无 endedAt + 年龄超线 + 双闸放行
900
+ function wildGate(t, r, runs) {
901
+ if (!t || !r || r.outcome !== 'running' || r.endedAt) return false
902
+ var atT = new Date(r.at).getTime()
903
+ if (!isFinite(atT) || atT <= 0) return false // 无/畸形起始时间:年龄不可知,宁少勿多不动
904
+ var hardMs = wildHardMinOf(r) * 60000
905
+ if (Date.now() - atT < hardMs) return false // 年龄闸:墙钟直比(now - at >= hardMin×60s)
906
+ var rec = runs[t.id]
907
+ if (rec && !rec.settled && String(rec.id) === String(r.id)) {
908
+ var recStart = Number(rec.startedAt)
909
+ if (!(recStart > 0) || Date.now() - recStart < hardMs) return false // 在飞 rec 年轻:超时臂在位,留给臂结算
910
+ }
911
+ return true
912
+ }
913
+ async function sweepWildRuns(sid, snap) {
914
+ if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0 // 瞬时读盘失败/空板:跳过,不写盘
915
+ var runs = runsFor(sid)
916
+ // 预扫快照(零写盘纪律同 sweepZombieRuns):无候选不进锁——安静板不因看门狗多出一次锁调用
917
+ var hasCandidate = false
918
+ for (var pi = 0; pi < snap.tasks.length && !hasCandidate; pi++) {
919
+ var pt = snap.tasks[pi]
920
+ if (!pt || pt.status === 'resolved' || pt.status === 'cancelled' || pt.status === 'archived') continue
921
+ var prs = Array.isArray(pt.runs) ? pt.runs : []
922
+ for (var pj = 0; pj < prs.length; pj++) { if (wildGate(pt, prs[pj], runs)) { hasCandidate = true; break } }
923
+ }
924
+ if (!hasCandidate) return 0
925
+ var settled = [] // 锁内结算清单 {taskId,rid,role,model,continuable}——锁外喂熔断/补停消费
926
+ var blockedResumes = [] // continuable 补停失败者:锁外二次小事务落 noResume(续跑资格作废)
927
+ await mutateLocked(sid, function (d) {
928
+ if (!d || !Array.isArray(d.tasks)) return null
929
+ var any = false
930
+ d.tasks.forEach(function (t) {
931
+ if (!t || t.status === 'resolved' || t.status === 'cancelled' || t.status === 'archived') return // 落定卡归 sweepZombieRuns
932
+ if (!Array.isArray(t.runs)) return
933
+ t.runs.forEach(function (r) {
934
+ if (!wildGate(t, r, runs)) return // 锁内重判(含双闸):预扫到持锁的窗口里卡面可能已变
935
+ var rid = String(r.id)
936
+ var role = r.role || '?'
937
+ var wasClaim = t.claimedBy === rid // worker 认领位曾指向本档(清占位前取值——清了就判不了)
938
+ var wasVerif = t.verifierRun === rid // verifier/hook-post 幂等位曾指向本档
939
+ r.outcome = 'timeout/error' // settleRun 超时臂同款结局(closeRunHistory 同字段直写,单事务原子)
940
+ r.endedAt = new Date().toISOString()
941
+ r.note = WILD_RUN_NOTE
942
+ if (wasClaim) { t.claimedBy = null; t.claimedAt = null }
943
+ if (wasVerif) { t.verifierRun = null; delete t.verifierRunAt }
944
+ t.lastError = String(WILD_ERR).slice(0, 300) // 失败原因落卡(settleWorker/settleVerifier 同款截断)
945
+ if (role === 'worker' && wasClaim && t.status === 'in-progress' && !t.escalation && !t.frozen) {
946
+ t.retryCount = (t.retryCount || 0) + 1
947
+ if (t.retryCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'worker 失败 x' + t.retryCount + '(' + WILD_ERR.slice(0, 120) + '),待人工介入') }
948
+ else { t.status = 'pending'; ah(t, 'in-progress', 'pending', 'system', 'worker 失败(' + WILD_ERR.slice(0, 80) + '),重新排队 (' + t.retryCount + '/3)') }
949
+ } else if (role === 'verifier' && wasVerif && t.status === 'verifying' && !t.escalation && !t.frozen) {
950
+ t.verifyRetries = (t.verifyRetries || 0) + 1
951
+ if (t.verifyRetries >= 3) {
952
+ t.escalation = { question: 'Verifier 连续 ' + t.verifyRetries + ' 次超硬超时野跑(重启后超时臂丢失)。交付物已完成,请人工验收:看板详情页直接通过/驳回,或 task_verify 裁决。', at: new Date().toISOString(), by: 'system' }
953
+ ah(t, 'verifying', 'verifying', 'system', 'verifier 野跑超时 x' + t.verifyRetries + ',转人工验收')
954
+ } else ah(t, 'verifying', 'verifying', 'system', 'verifier 野跑超硬超时,重新排队审查 (' + t.verifyRetries + '/3)')
955
+ } else if (role === 'hook-post' && wasVerif && t.hooks && t.hooks.post && t.hooks.post.state !== 'done') {
956
+ t.hooks.post.pending = true // 幂等占用位已清 → 重挂待跑标记,下轮 cycle 重新 spawn 收口
957
+ } else {
958
+ ah(t, t.status, t.status, 'system', '野跑 run 留档墙钟超时关账(角色 ' + role + ')')
959
+ }
960
+ settled.push({ taskId: t.id, rid: rid, role: role, model: r.model || '', continuable: r.continuable === true })
961
+ any = true
962
+ })
963
+ })
964
+ return any ? d : null // 无改动返回 null → 不写盘
965
+ }, true) // skipKick:本轮已是 poolCycle,重派由本轮持锁段按新鲜 d 自然承接
966
+ if (!settled.length) return 0
967
+ // ===== 锁外收尾:喂熔断连击 + 野跑子会话补停 =====
968
+ // 熔断/补停只盖 worker/verifier(hungZeroOutputSweep / stopSettledOneShot 同款边界——hook 与
969
+ // decomposer 的模型不进连击面、子会话不由看门狗停)。
970
+ var subagents = ctx.subagents
971
+ var parent = rootForSession(sid)
972
+ for (var wi = 0; wi < settled.length; wi++) {
973
+ var sw = settled[wi]
974
+ if (sw.model && (sw.role === 'worker' || sw.role === 'verifier')) feedModelFailure(sid, sw.model, WILD_CIRCUIT_WHY)
975
+ if (!subagents || typeof subagents.interrupt !== 'function') continue // 老宿主/老桩无此能力:跳过补停(关账不受影响)
976
+ if (sw.role !== 'worker' && sw.role !== 'verifier') continue
977
+ if (!parent) { if (sw.role === 'worker' && sw.continuable) blockedResumes.push(sw); continue } // 无活 root 可授权(endContinuable resumeBlocked 同款)
978
+ try { subagents.interrupt(sw.rid, { kind: 'ancestor', agent: parent }) }
979
+ catch (e) {
980
+ console.error('[task-board] 野跑子会话补停失败(会话可能仍在空转,只影响 token)task ' + sw.taskId + ' child ' + sw.rid + ':', String(e))
981
+ if (sw.role === 'worker' && sw.continuable) blockedResumes.push(sw)
982
+ }
983
+ }
984
+ // continuable 补停失败 → noResume 落档:该会话不可信(interrupt 打不到),重派直接 fresh spawn 不空唤醒
985
+ // (endContinuable → closeRunHistory(flags.noResume) 同款语义;留档已关账故走二次小事务直写)。
986
+ if (blockedResumes.length) {
987
+ try {
988
+ await mutateLocked(sid, function (d) {
989
+ if (!d || !Array.isArray(d.tasks)) return null
990
+ var anyR = false
991
+ blockedResumes.forEach(function (s2) {
992
+ var t2 = d.tasks.find(function (x) { return x.id === s2.taskId })
993
+ if (!t2 || !Array.isArray(t2.runs)) return
994
+ t2.runs.forEach(function (r2) { if (r2 && String(r2.id) === s2.rid && r2.noResume !== true) { r2.noResume = true; anyR = true } })
995
+ })
996
+ return anyR ? d : null
997
+ }, true)
998
+ } catch (e) { console.error('[task-board] 野跑 noResume 落档失败:', String(e)) }
999
+ }
1000
+ console.log('[task-board] 野跑看门狗结算 ' + settled.length + ' 条:' + settled.map(function (x) { return x.taskId + '/' + x.rid + '/' + x.role }).join(', '))
1001
+ return settled.length
1002
+ }
1003
+
870
1004
  // ===== 幽灵占位回收(池冻结根修,task-muwlepg6)=====
871
1005
  // 病根(活体实证):活跃表项(rec)的**唯一**摘除点是各结算通道(事件通道 settleRun /
872
1006
  // 上报通道 settleReportedRun / 手动终止)。任何一条漏走(worker 经 board_report 完成但当时
@@ -950,5 +1084,6 @@ export function createSettle(opts) {
950
1084
  // 重指本实例——同一 deps 接线(mutateLocked/health/通知类/kickCycle 同源),语义与搬移前一致。
951
1085
  // 拆分第 7 刀:reconcile 域四函数(accumulateRunUsage/reconcileRoot/sweepZombieRuns/reapGhostRecs)
952
1086
  // 从返回集导出——dispatch.mjs 的 poolCycleBody 三挂点与 return API(rpc 侧手动终止关账消费)重指本实例。
953
- return { settleRun: settleRun, settleRunRecord: settleRunRecord, settleReportedRun: settleReportedRun, onAgentStatus: onAgentStatus, settleWorker: settleWorker, accumulateRunUsage: accumulateRunUsage, reconcileRoot: reconcileRoot, sweepZombieRuns: sweepZombieRuns, reapGhostRecs: reapGhostRecs }
1087
+ // 野跑看门狗(task-mvyzshi7)随返回集导出:dispatch.mjs 的 poolCycleBody 挂点消费(sweepZombieRuns 之后)。
1088
+ return { settleRun: settleRun, settleRunRecord: settleRunRecord, settleReportedRun: settleReportedRun, onAgentStatus: onAgentStatus, settleWorker: settleWorker, accumulateRunUsage: accumulateRunUsage, reconcileRoot: reconcileRoot, sweepZombieRuns: sweepZombieRuns, sweepWildRuns: sweepWildRuns, reapGhostRecs: reapGhostRecs }
954
1089
  }
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.3",
4
+ "version": "1.9.4",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
@@ -799,3 +799,175 @@ test('挂死检测⑥(task-mvrt9tn5):2s 延迟快模型 600s 内静默不
799
799
  assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/kimi-k3'], 1, '真挂死照喂熔断连击(挂死=模型面故障语义不变)')
800
800
  } finally { fs.rmSync(L.root, { recursive: true, force: true }) }
801
801
  })
802
+
803
+ // ===== 野跑看门狗(task-mvyzshi7):非落定卡 running 留档超 hardMin 墙钟即结算 =====
804
+ // 事故(本体板 2026-11-02 实证,task-mvyyxs5u):DSH 重启把 armTimeouts 两级超时臂(内存 setTimeout)
805
+ // 清零,reconcileRoot 只重建 continuable Worker 的观测与超时臂——一次性 verifier run 的超时臂永不恢复,
806
+ // hardMin 硬超时彻底失效,run 无任何上限可无限野跑(verifier 跑 8 小时 2 分仍在活跃写盘;日志持续增长,
807
+ // 600s 零增长挂死闸也抓不到)。修法:每轮 poolCycle 墙钟兜底扫描 sweepWildRuns(lib/settle.mjs)。
808
+ // 本套与上方挂死套同一 mkGateDispatch harness;subagents 均不带 listChildren → reconcile 整段跳过,
809
+ // 看门狗是唯一收口者(隔离断言)。留档年龄用真实墙钟(mock 时钟只接管 ctx.timer),at 直接预置到过去。
810
+ var wildAt = function (minsAgo) { return new Date(Date.now() - minsAgo * 60000).toISOString() }
811
+ var wildSubagents = function (stopped, extra) {
812
+ var s = {
813
+ list: function () { return ['mock'] },
814
+ getProvider: function () { return { inheritsParentContext: false } },
815
+ start: async function () { return { id: 'run-new', result: new Promise(function () {}), dispose: async function () {} } },
816
+ startContinuable: async function () { throw new Error('本用例不该 fresh spawn') },
817
+ sendMessage: async function () { throw new Error('本用例零续跑') },
818
+ interrupt: function (childId, authority) { if (stopped) stopped.push({ childId: childId, authority: authority }) },
819
+ }
820
+ for (var k in (extra || {})) s[k] = extra[k]
821
+ return s
822
+ }
823
+
824
+ test('野跑看门狗①(断言①+⑥):verifying 卡超龄 verifier 留档(重启丢臂原形)→ 关账 timeout/error+清占位+连击+1+补停+当轮重派新 verifier;新 run 二轮零误伤', async () => {
825
+ var t = mkGateTask({ id: 'wv1', status: 'verifying', verifierRun: 'v-wild', verifierRunAt: '2026-01-01T00:00:00Z',
826
+ runs: [{ role: 'verifier', id: 'v-wild', at: wildAt(8 * 60), model: 'prov/glm-5.2', outcome: 'running', hardMin: 360 }] })
827
+ var stopped = []
828
+ var h = mkGateDispatch(mkGateBoard([t]), { subagents: wildSubagents(stopped, {
829
+ startContinuable: async function () { throw new Error('verifier 恒走一次性面') },
830
+ }) })
831
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
832
+ // 断言①:结算 timeout/error + 留痕 + 占位清除(8h 野跑的墙钟收口)
833
+ assert.equal(t.runs[0].outcome, 'timeout/error', '超龄留档关账 timeout/error(settleRun 超时臂同款结局)')
834
+ assert.ok(t.runs[0].endedAt, '关账写 endedAt')
835
+ assert.equal(t.runs[0].note, '野跑看门狗墙钟结算(重启后超时臂丢失)', '关账 note 留痕')
836
+ assert.match(String(t.lastError || ''), /超出硬超时上限.*野跑/, 'lastError 野跑文案落卡')
837
+ assert.equal(t.status, 'verifying', '验收卡保持 verifying(重排审查语义)')
838
+ assert.equal(t.verifyRetries, 1, '审查重试计数 +1(settleVerifier 失败臂同款)')
839
+ assert.ok((t.history || []).some(function (x) { return /野跑超硬超时,重新排队审查 \(1\/3\)/.test(x.note || '') }), '重排审查 history 留痕')
840
+ assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/glm-5.2'], 1, '熔断连击 +1(按模型面故障结算)')
841
+ assert.ok(!h.state.badModels[GATE_SID + '|prov/glm-5.2'], '连击 1 < 3 不拉黑(看门狗保守:不即时欺负模型)')
842
+ assert.equal(stopped.length, 1, '野跑子会话补停恰一枪(8h 空烧根修:关账必须连带停会话)')
843
+ assert.equal(stopped[0].childId, 'v-wild')
844
+ assert.equal(stopped[0].authority.kind, 'ancestor', 'authority 形状与 stopSettledOneShot/endContinuable 同款')
845
+ // 断言⑥:占位清除后当轮重派,新 verifier run 留档不受影响
846
+ assert.equal(t.verifierRun, 'run-new', 'verifierRun 已清并当轮换新 verifier')
847
+ assert.equal(t.runs.length, 2, '重派追加新留档')
848
+ assert.equal(t.runs[1].outcome, 'running', '新 run 留档照常挂 running')
849
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
850
+ assert.equal(t.runs[1].outcome, 'running', '二轮零误伤:新 run 年轻,看门狗不碰')
851
+ assert.equal(t.runs[0].outcome, 'timeout/error', '已关账留档不被二次改写(幂等)')
852
+ assert.equal(stopped.length, 1, '新 run 不补停')
853
+ assert.equal(t.verifyRetries, 1, '不重复计数(幂等)')
854
+ })
855
+
856
+ test('野跑看门狗②④(断言②+④):年龄未超线零动作;在飞 rec(reconcile 重建/续跑刚接手)双闸零误伤——超龄留档也不抢在飞臂的活', async () => {
857
+ // reconcile 重建形状:重启后 rec 已重建+重挂超时臂(startedAt=5 分钟前),留档 at 还是重启前的(超龄 7h)
858
+ var reb = mkGateTask({ id: 'wreb', status: 'in-progress', claimedBy: 'child-reb', claimedAt: new Date().toISOString(),
859
+ runs: [{ role: 'worker', id: 'child-reb', at: wildAt(7 * 60), model: 'prov/glm-5.2', outcome: 'running', hardMin: 360, continuable: true }] })
860
+ // 断言②形状:无 rec 但年龄 10 分钟 << hardMin 360 → 零动作
861
+ var yng = mkGateTask({ id: 'wyng', status: 'in-progress', claimedBy: 'w-young', claimedAt: new Date().toISOString(),
862
+ runs: [{ role: 'worker', id: 'w-young', at: wildAt(10), model: 'prov/glm-5.2', outcome: 'running', hardMin: 360 }] })
863
+ var stopped = []
864
+ var h = mkGateDispatch(mkGateBoard([reb, yng]), { subagents: wildSubagents(stopped) })
865
+ h.runs['wreb'] = { id: 'child-reb', childId: 'child-reb', continuable: true, ran: true, run: null, role: 'worker', taskId: 'wreb', startedAt: Date.now() - 5 * 60000, model: 'prov/glm-5.2', settled: false }
866
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
867
+ // 断言④:留档超龄但 rec 年轻(超时臂在位)→ 双闸放行在飞臂,看门狗零动作
868
+ assert.equal(reb.runs[0].outcome, 'running', '在飞 rec 年轻:超龄留档不结算(reconcile 重建/续跑零误杀)')
869
+ assert.equal(reb.runs[0].endedAt, undefined, '不写 endedAt')
870
+ assert.equal(reb.status, 'in-progress', '卡面不动')
871
+ assert.equal(reb.claimedBy, 'child-reb', '认领位不动')
872
+ assert.equal(reb.retryCount, undefined, '零失败计数')
873
+ assert.ok(!(reb.history || []).some(function (x) { return /野跑/.test(x.note || '') }), '零野跑留痕')
874
+ // 断言②:年龄未超线且无 rec → 零动作
875
+ assert.equal(yng.runs[0].outcome, 'running', '年龄未超线:不结算(零动作)')
876
+ assert.equal(yng.runs[0].endedAt, undefined, '不写 endedAt')
877
+ assert.equal(yng.status, 'in-progress', '零动作')
878
+ assert.equal(yng.lastError, undefined, '零 lastError')
879
+ assert.deepEqual(stopped, [], '零补停(双闸与年龄闸都拦住,无任何误伤面)')
880
+ assert.ok(!h.state.modelFailStreak || !Object.keys(h.state.modelFailStreak).length, '零熔断连击')
881
+ })
882
+
883
+ test('野跑看门狗③(断言③):hardMin 缺省 360 生效(5h 未到线不动);留档自带 hardMin=60 覆盖缺省(70min 超线结算)——worker 失败臂同款重排', async () => {
884
+ var ndef = mkGateTask({ id: 'wnd', status: 'in-progress', claimedBy: 'w-ndef', claimedAt: new Date().toISOString(),
885
+ runs: [{ role: 'worker', id: 'w-ndef', at: wildAt(5 * 60), model: 'prov/glm-5.2', outcome: 'running' }] }) // 无 hardMin 字段(老留档形状)→ 缺省 360
886
+ var ovr = mkGateTask({ id: 'wovr', status: 'in-progress', claimedBy: 'w-hm60', claimedAt: new Date().toISOString(),
887
+ runs: [{ role: 'worker', id: 'w-hm60', at: wildAt(70), model: 'prov/glm-5.2', outcome: 'running', hardMin: 60 }] })
888
+ var board = mkGateBoard([ndef, ovr])
889
+ board.boardMode = 'manual' // 关 worker 派发:隔离看门狗结算面(当轮重派面由用例①覆盖)
890
+ var stopped = []
891
+ var h = mkGateDispatch(board, { subagents: wildSubagents(stopped) })
892
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
893
+ assert.equal(ndef.runs[0].outcome, 'running', '无 hardMin → 缺省 360 生效:5h(300min)< 360min 不结算')
894
+ assert.equal(ndef.status, 'in-progress', '缺省闸内零动作')
895
+ assert.equal(ovr.runs[0].outcome, 'timeout/error', '留档自带 hardMin=60 覆盖缺省:70min > 60min 超线结算')
896
+ assert.ok(ovr.runs[0].endedAt, '关账写 endedAt')
897
+ assert.equal(ovr.status, 'pending', 'worker 失败臂同款:回 pending 重新排队')
898
+ assert.equal(ovr.claimedBy, null, '认领位清除')
899
+ assert.equal(ovr.claimedAt, null, '认领时间清除')
900
+ assert.equal(ovr.retryCount, 1, '失败计数 +1')
901
+ assert.match(String(ovr.lastError || ''), /超出硬超时上限/, '失败原因落卡')
902
+ assert.ok((ovr.history || []).some(function (x) { return /worker 失败(.*野跑.*),重新排队 \(1\/3\)/.test(x.note || '') }), '重排 history 留痕')
903
+ assert.deepEqual(stopped.map(function (s) { return s.childId }), ['w-hm60'], '补停只打超线结算的野跑子会话')
904
+ assert.equal(h.state.modelFailStreak[GATE_SID + '|prov/glm-5.2'], 1, 'worker 野跑同喂熔断连击')
905
+ })
906
+
907
+ test('野跑看门狗⑤(断言⑤):落定卡超龄 running 留档仍走原 sweepZombieRuns 语义(stale-closed),看门狗不抢不碰', async () => {
908
+ var res = mkGateTask({ id: 'wres', status: 'resolved', resolvedAt: '2026-01-01T00:00:00Z', verifierRun: 'v-dead', verifierRunAt: '2026-01-01T00:00:00Z',
909
+ runs: [{ role: 'verifier', id: 'v-dead', at: wildAt(8 * 60), model: 'prov/glm-5.2', outcome: 'running', hardMin: 360 }] })
910
+ var stopped = []
911
+ var h = mkGateDispatch(mkGateBoard([res]), { subagents: wildSubagents(stopped) })
912
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
913
+ assert.equal(res.runs[0].outcome, 'stale-closed', '落定卡归 sweepZombieRuns:stale-closed(非看门狗的 timeout/error——管辖权不重叠)')
914
+ assert.equal(res.runs[0].note, 'host 重启僵尸留档清扫', '僵尸清扫 note 原样')
915
+ assert.equal(res.verifierRun, null, '僵尸清扫清位原样')
916
+ assert.equal(res.lastError, undefined, '看门狗不落 lastError(没抢跑)')
917
+ assert.deepEqual(stopped, [], '落定卡留档不由看门狗补停(reconcile 孤儿补停/僵尸清扫辖区,与本卡改动无关)')
918
+ })
919
+
920
+ test('野跑看门狗⑥:continuable 野跑补停失败(interrupt 抛错)→ noResume 落档(重派回退 fresh spawn,不空唤醒)', async () => {
921
+ var t = mkGateTask({ id: 'wnr', status: 'in-progress', claimedBy: 'child-wild', claimedAt: new Date().toISOString(),
922
+ runs: [{ role: 'worker', id: 'child-wild', at: wildAt(8 * 60), model: 'prov/glm-5.2', outcome: 'running', hardMin: 360, continuable: true }] })
923
+ var board = mkGateBoard([t])
924
+ board.boardMode = 'manual'
925
+ var h = mkGateDispatch(board, { subagents: wildSubagents(null, {
926
+ interrupt: function () { throw new Error('杀无可杀(会话已死/无 root 授权)') },
927
+ }) })
928
+ var origErr = console.error; console.error = function () {} // 补停失败留痕日志
929
+ try {
930
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
931
+ assert.equal(t.runs[0].outcome, 'timeout/error', '超龄关账照旧')
932
+ assert.equal(t.runs[0].noResume, true, 'interrupt 失败 → noResume 落档(续跑资格作废,重派直接 fresh spawn)')
933
+ assert.equal(t.status, 'pending', '回 pending 重排')
934
+ assert.equal(t.retryCount, 1, '失败计数照旧')
935
+ } finally { console.error = origErr }
936
+ })
937
+
938
+ test('野跑看门狗⑦:双闸兜底半边——rec 在表但年龄也超线(臂应到期却没结算的形状)→ 照样结算;残 rec 由同轮幽灵回收摘除收尾(口径不另立)', async () => {
939
+ var t = mkGateTask({ id: 'wage', status: 'in-progress', claimedBy: 'child-age', claimedAt: new Date().toISOString(),
940
+ runs: [{ role: 'worker', id: 'child-age', at: wildAt(8 * 60), model: 'prov/glm-5.2', outcome: 'running', hardMin: 360, continuable: true }] })
941
+ var board = mkGateBoard([t])
942
+ board.boardMode = 'manual'
943
+ var stopped = []
944
+ var h = mkGateDispatch(board, { subagents: wildSubagents(stopped) })
945
+ // rec 在表且年龄也超线(startedAt 7h 前)——双闸的「rec 存在但 age 也超线」半边放行结算
946
+ h.runs['wage'] = { id: 'child-age', childId: 'child-age', continuable: true, ran: true, run: null, role: 'worker', taskId: 'wage', startedAt: Date.now() - 7 * 3600000, model: 'prov/glm-5.2', settled: false }
947
+ await h.dispatch.poolCycle(GATE_SID); await flushGate()
948
+ assert.equal(t.runs[0].outcome, 'timeout/error', 'rec 在表但年龄也超线 → 结算(双闸放行的兜底半边)')
949
+ assert.equal(t.status, 'pending', '回 pending 重排')
950
+ assert.ok(!h.runs['wage'], '被结算卡上的残 rec 由同轮幽灵回收按卡面失活摘除(看门狗只结算不摘表项,口径不另立)')
951
+ assert.equal(t.runs[0].outcome, 'timeout/error', 'reap 收尾不覆盖已关账结局(closeRunHistory 只关 running 条目——双结算收敛为单结局)')
952
+ assert.deepEqual(stopped.map(function (s) { return s.childId }), ['child-age'], '补停照打(超时臂同款:结算即停)')
953
+ })
954
+
955
+ test('野跑看门狗⑧(源码级):sweepWildRuns 定义/年龄闸/双闸/落定卡门禁/连击/补停 + settle 返回集导出 + poolCycleBody 挂点次序', () => {
956
+ const stl = readFileSync(new URL('../lib/settle.mjs', import.meta.url), 'utf8')
957
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
958
+ assert.match(stl, /async function sweepWildRuns\(sid, snap\) \{/, '看门狗函数定义')
959
+ assert.match(stl, /function wildHardMinOf\(r\) \{ var hm = Number\(r && r\.hardMin\); return hm > 0 \? hm : 360 \}/, 'hardMin:留档自带值优先,缺省 360')
960
+ assert.match(stl, /if \(Date\.now\(\) - atT < hardMs\) return false/, '年龄闸:墙钟直比(不依赖内存计时器——重启丢臂的兜底)')
961
+ assert.match(stl, /if \(!\(recStart > 0\) \|\| Date\.now\(\) - recStart < hardMs\) return false/, '保守双闸:在飞 rec 年轻 → 留给在飞臂(reconcile 重建/续跑零误伤)')
962
+ assert.match(stl, /pt\.status === 'resolved' \|\| pt\.status === 'cancelled' \|\| pt\.status === 'archived'/, '预扫只过非落定卡(落定卡归 sweepZombieRuns,口径不重叠)')
963
+ assert.match(stl, /r\.outcome = 'timeout\/error'/, '关账结局=超时臂同款')
964
+ assert.match(stl, /r\.note = WILD_RUN_NOTE/, '关账 note 留痕')
965
+ assert.match(stl, /feedModelFailure\(sid, sw\.model, WILD_CIRCUIT_WHY\)/, '喂熔断连击(三连拉黑,非即时熔断)')
966
+ assert.match(stl, /subagents\.interrupt\(sw\.rid, \{ kind: 'ancestor', agent: parent \}\)/, '野跑子会话补停(8h 空烧根修)')
967
+ assert.match(stl, /sweepWildRuns: sweepWildRuns/, 'settle 返回集导出')
968
+ // 挂点次序:sweepZombieRuns(落定卡)→ sweepWildRuns(非落定卡)→ reapGhostRecs
969
+ const iZ = dsp.indexOf('settle.sweepZombieRuns(sid, snap)')
970
+ const iW = dsp.indexOf('settle.sweepWildRuns(sid, snap)')
971
+ const iR = dsp.indexOf('reapedN = await settle.reapGhostRecs(sid, snap)')
972
+ assert.ok(iZ >= 0 && iW > iZ && iR > iW, 'poolCycleBody 挂点次序:僵尸清扫 → 野跑看门狗 → 幽灵回收')
973
+ })