dsh-agent-board 1.7.9 → 1.8.1

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/lib/dispatch.mjs CHANGED
@@ -7,7 +7,7 @@ import { statSync } from 'node:fs'
7
7
  import * as core from './core.mjs'
8
8
  import { readRunUsage, findRunLog, readLogBytes, readLogFrames } from './usage.mjs'
9
9
  import { splitRuleOf, pushRejectLesson } from './policy.mjs'
10
- const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
10
+ const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
11
11
 
12
12
  export function createDispatch(ctx, state, deps) {
13
13
  // 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
@@ -174,6 +174,59 @@ export function createDispatch(ctx, state, deps) {
174
174
  try { var log = findRunLog(childId, sessionsRoot); if (!log) return 0; return statSync(log).size || 0 } catch (_) { return 0 }
175
175
  }
176
176
 
177
+ // ===== spawn 落闸锁内重验(task-mv6c7cdm:terminate→重派双 run 竞态根治,实证 n-mv65bhk0mg5w)=====
178
+ // 竞态原形:claim 占位在锁内、spawn 执行在锁外,中间隔着 sendMessage 冷复活 / startContinuable 的
179
+ // 慢 await——terminate 落进这个窗口时在活跃表里找不到在途 spawn(rec 尚未注册),interrupt 打不到
180
+ // 它;卡面已被翻回 pending,spawn 返回后照旧落闸 → 同卡同秒双 run(旧子复活 + 新子 fresh spawn
181
+ // 各自烧上下文后报丢)。红线:落闸必须以**锁内新鲜快照**重验本轮 claim 仍有效(与 claim CAS 同
182
+ // 一把锁、同一份文档),不能只信锁外快照:
183
+ // · worker 一律验:卡仍 in-progress 且认领位仍是本轮占位 'spawn-pending'(terminate 会清占位并
184
+ // 翻 pending,两个条件任一失守都说明本轮 claim 已作废);
185
+ // · 依赖补验(task-mv9yupm4,dependsOn 后补不阻断根修):publish→claim→spawn 在途窗口内
186
+ // update-task 后补的 dependsOn 必须一并重验——与 status/claimedBy 读同一份锁内快照;依赖刚被
187
+ // 补上且未满足(depsSatisfied=false)即否决本轮 spawn(publish 时空依赖抢出的在途 spawn 不
188
+ // 再漏网;正常路径 pickDispatch 的 depsSatisfied 门控只闸「挑候选」一刻,闸不到在途窗口);
189
+ // · 续跑追加验:锁内重算的 resumeTarget 仍指向同一个 childId(terminate 命中活跃 rec 时会把
190
+ // 留档关成 'terminated',续跑资格随之失效——锁内重算天然覆盖这条)。
191
+ // 用法契约:spawn 返回后**零 await 先登记 rec**(保住事件可见性既有纪律),再过本闸门;
192
+ // 闸门否决时由调用方逆向摘干净(settled 旗 + 摘 rec + 停子会话)。这样 terminate 只剩两类落点:
193
+ // 落在 spawn 在途期(当时无 rec,闸门读锁内快照发现卡面已翻走 → 否决)/ 落在登记之后(命中
194
+ // 活跃 rec,走 terminate 自己的正常 interrupt+关账路径)——不再存在「在途 spawn 看不见」的
195
+ // 第三类窗口。纯验证:回调恒返回 null → 不写盘不 kick(mutateLocked 契约);
196
+ // 闸门自身异常按弃权处理(宁停勿双——占位遗留由 2min 占位超时回收兜底,下轮重派)。
197
+ async function spawnGateOk(sid, taskId, resumeId) {
198
+ var ok = false
199
+ try {
200
+ await mutateLocked(sid, function (d) {
201
+ var t = d.tasks.find(function (x) { return x.id === taskId })
202
+ ok = !!(t && t.status === 'in-progress' && t.claimedBy === 'spawn-pending' && depsSatisfied(d, t) && (!resumeId || resumeTarget(t) === resumeId))
203
+ return null
204
+ }, true)
205
+ } catch (e) { console.error('[task-board] spawn 闸门重验异常(按弃权处理,宁停勿双)task ' + taskId + ':', String(e)); ok = false }
206
+ return ok
207
+ }
208
+
209
+ // ===== 依赖否决的占位回退(task-mv9yupm4:dependsOn 后补撞在途 spawn 的收口半步)=====
210
+ // 闸门因「锁内快照 depsSatisfied=false」否决时,卡面仍停在 in-progress + spawn-pending
211
+ // (update-task 写依赖不动状态机)——不像 terminate 竞态那样有翻卡方顺手清占位,不回退就
212
+ // 只能干等 2min 占位超时回收。条件化回退:仅当卡面仍停在本轮占位且依赖仍未满足时才翻回
213
+ // pending(terminate 竞态已翻走 / 非依赖原因的否决 / 依赖恰在闸门后落定——一律不动,零改动
214
+ // 不写盘,与占位 swap 同纪律)。skipKick:依赖未满足时本也派不出(pickDispatch 的
215
+ // depsSatisfied 门控),依赖落定那笔写(resolve/archive 走 mutateLocked 默认 kick)自己 kick。
216
+ async function revertDepsVeto(sid, taskId) {
217
+ try {
218
+ await mutateLocked(sid, function (d) {
219
+ var t2 = d.tasks.find(function (x) { return x.id === taskId })
220
+ if (!t2) return null
221
+ if (t2.status !== 'in-progress' || t2.claimedBy !== 'spawn-pending') return null
222
+ if (depsSatisfied(d, t2)) return null
223
+ t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null
224
+ ah(t2, 'in-progress', 'pending', 'system', 'spawn 落闸重验:dependsOn 后补且未满足,本轮派发弃权回待办,依赖落定后自动重派')
225
+ return { ok: true }
226
+ }, true)
227
+ } catch (_) {}
228
+ }
229
+
177
230
  async function spawnOneShot(sid, t, role) {
178
231
  var subagents = ctx.subagents; if (!subagents) return null
179
232
  // 兜底保留(真异常时有用):poolCycle 入口已有 root 存活早闸门,正常派发路径不会到达这里;
@@ -262,9 +315,36 @@ export function createDispatch(ctx, state, deps) {
262
315
  var rec = useContinuable
263
316
  ? { id: childId, childId: childId, continuable: true, ran: false, run: null, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
264
317
  : { id: String(run.id), run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
318
+ // ⚠️ spawn 返回 → 登记 rec 必须**零 await**(既有纪律):continuable 子会话的 running→idle 事件
319
+ // 随时可能到达,事件通道必须马上能在活跃表里看到它——否则 idle 被 rec.ran 守卫挡下,结算干等硬超时。
265
320
  runsFor(sid)[t.id] = rec
266
321
  if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
267
322
  dispatchedEver[sid][String(rec.id)] = true
323
+ // ===== 落闸锁内重验(task-mv6c7cdm,仅 worker 角色;与续跑路径同一闸门 spawnGateOk)=====
324
+ // spawn 在途窗口内卡面可能已被 terminate 等路径翻走(占位清、回 pending)——此时本次派发基于
325
+ // 过期快照,必须弃权:settled 旗堵迟到结算 + 摘 rec + 停掉刚建的子会话(continuable=interrupt
326
+ // 留存口径与 terminate 一致;一次性=dispose),不落 run 留档——它在卡面从未存在过,卡面由
327
+ // terminate 那笔写重排(pending),下一轮 cycle 按现状重新决策。
328
+ // 为什么登记在闸门之前:闸门是持锁慢操作(锁内重读新鲜快照),若夹在「spawn 返回」与「登记」
329
+ // 之间会重新撕开事件可见性窗口——登记先行保住既有纪律,闸门否决时再逆向摘干净即可。
330
+ // verifier/hook 不验:verifier 的手动派发通道(dispatch-task)不设占位,验了会误杀正常手动派发;
331
+ // hook 的占位语义不同(state 机幂等)——二者都不在本次竞态实证范围内。
332
+ if (role === 'worker') {
333
+ var gateOk = await spawnGateOk(sid, t.id, '')
334
+ if (!gateOk) {
335
+ rec.settled = true // 先立旗:迟到的 idle 结算与超时臂一并放行空转
336
+ if (runsFor(sid)[t.id] === rec) delete runsFor(sid)[t.id]
337
+ try {
338
+ if (useContinuable) { if (typeof subagents.interrupt === 'function') subagents.interrupt(childId, { kind: 'ancestor', agent: parent }) }
339
+ else if (run && typeof run.dispose === 'function') await run.dispose()
340
+ } catch (_) {}
341
+ // 依赖否决收口(task-mv9yupm4):dependsOn 后补导致的否决无人翻卡(update-task 不动状态机),
342
+ // 条件化把仍停在本轮占位的卡翻回 pending;terminate 竞态的翻卡已由 terminate 完成,此调用零改动。
343
+ await revertDepsVeto(sid, t.id)
344
+ console.error('[task-board] spawn 落闸被锁内重验拒绝(卡面已变更,本次派发弃权)task ' + t.id + ' role ' + role)
345
+ return { vetoed: true }
346
+ }
347
+ }
268
348
  // 历史会话留档:每次派发都追加一条 {role,id,at,model},任务完成后仍可回看
269
349
  // 各阶段(含重试的第 1/2/3 次 Worker)会话——否则 claimedBy/verifierRun 只留最后一次。
270
350
  // **必须 await**(task-muwkhqf8):上报通道的收尾(settleReportedRun → closeRunHistory)要求在
@@ -396,10 +476,52 @@ export function createDispatch(ctx, state, deps) {
396
476
  // NOT_RESUMABLE / 邻接校验失败 / 任何错:一律回退 fresh spawn(本函数不抛,调用方按 rec 为 null 处理)。
397
477
  return { fallback: String(e) }
398
478
  }
479
+ // ⚠️ sendMessage 返回 → 登记 rec 必须**零 await**(既有纪律):冷复活子会话的 running→idle
480
+ // 随时可能到达,事件通道必须马上能在活跃表里看到它——否则 idle 被 rec.ran 守卫挡下,结算干等硬超时。
399
481
  var rec = { id: childId, childId: childId, continuable: true, resumed: true, ran: false, run: null, role: 'worker', taskId: t.id, startedAt: Date.now(), model: '', settled: false, baselineBytes: logSizeOf(childId) }
400
482
  runsFor(sid)[t.id] = rec
401
483
  if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
402
484
  dispatchedEver[sid][childId] = true
485
+ // ===== 落闸锁内重验(task-mv6c7cdm):sendMessage 冷复活是慢操作,在途期间卡面可能已被
486
+ // terminate 翻走(它看不到本次在途续跑——当时活跃表无 rec,interrupt 打不到、关账轮不到)。
487
+ // 闸门以锁内新鲜快照重验:本轮占位仍在 + 锁内重算 resumeTarget 仍指向同一 childId。
488
+ // 拒绝即弃权三件套:
489
+ // ① settled 旗 + 摘 rec——迟到的 idle 结算与本轮派发痕迹一并失效;
490
+ // ② interrupt 刚复活的子会话——否则它脱离观测空跑烧上下文(实证 f3f9a66f 空烧 64k tok);
491
+ // ③ 旧 run 留档立 noResume 旗——terminate 命中活跃 rec 时 closeRunHistory('terminated') 会让
492
+ // 续跑资格失效;这里留档早已落定关不动,补 noResume 达同一语义(用户 terminate 就是要换掉它),
493
+ // 下一轮 cycle 走 fresh spawn 新 Worker 而不是再次复活它。
494
+ // 不落的只有续跑留档——本次派发在卡面从未存在过。
495
+ // 为什么登记在闸门之前:闸门是持锁慢操作,夹在「sendMessage 返回」与「登记」之间会重新撕开
496
+ // 事件可见性窗口——登记先行保住既有纪律,闸门否决时再逆向摘干净(与 spawnOneShot 同款)。
497
+ var gateOk = await spawnGateOk(sid, t.id, childId)
498
+ if (!gateOk) {
499
+ rec.settled = true // 先立旗:迟到的 idle 结算与超时臂一并放行空转
500
+ if (runsFor(sid)[t.id] === rec) delete runsFor(sid)[t.id]
501
+ try { if (typeof subagents.interrupt === 'function') subagents.interrupt(childId, { kind: 'ancestor', agent: parent }) } catch (_) {}
502
+ try {
503
+ await mutateLocked(sid, function (d) {
504
+ var t2 = d.tasks.find(function (x) { return x.id === t.id })
505
+ if (!t2 || !Array.isArray(t2.runs)) return null
506
+ for (var ri = t2.runs.length - 1; ri >= 0; ri--) {
507
+ var r2 = t2.runs[ri]
508
+ if (!r2 || r2.role !== 'worker') continue
509
+ if (String(r2.id) !== String(childId)) break // 最新 worker 留档不是它 → 续跑资格早已易主,不动
510
+ if (r2.noResume === true) return null
511
+ if (r2.outcome !== 'timeout/error' && r2.outcome !== 'incomplete') return null // 仍 running 等形态归结算/终止路径管
512
+ r2.noResume = true
513
+ ah(t2, t2.status, t2.status, 'system', '派发落闸重验未通过(terminate 竞态/依赖后补):续跑弃权,旧子会话不再续跑')
514
+ return { ok: true }
515
+ }
516
+ return null
517
+ }, true)
518
+ } catch (_) {}
519
+ // 依赖否决收口(task-mv9yupm4):dependsOn 后补导致的否决无人翻卡(update-task 不动状态机),
520
+ // 条件化把仍停在本轮占位的卡翻回 pending;terminate 竞态的翻卡已由 terminate 完成,此调用零改动。
521
+ await revertDepsVeto(sid, t.id)
522
+ console.error('[task-board] 续跑落闸被锁内重验拒绝(terminate 竞态/依赖后补,已弃权并 interrupt 复活子会话)task ' + t.id + ' child ' + childId)
523
+ return { vetoed: true }
524
+ }
403
525
  armTimeouts(sid, rec, t, c)
404
526
  // runs[] 追加一条**续跑记录**(role:'worker', id:childId 与原 run 同 id=同一会话, resume:true):
405
527
  // 详情页可区分首派/续跑,且它成为续跑资格判定的最新依据。
@@ -414,6 +536,57 @@ export function createDispatch(ctx, state, deps) {
414
536
  // 无论从哪条入口进来(事件通道 settleContinuable / 上报通道 board_report),run 记录的收尾动作
415
537
  // 必须一致:①关 run 结局(closeRunHistory)②usage 落账(seq 水位线增量)③摘除软/硬超时臂。
416
538
  // 三条各自幂等:重复结算(idle 事件重入 / 上报与事件双通道先后到达)不二次关账、不二次记账。
539
+ // ===== 结算≠停止根修(task-mv8wuznb,ontology 实证 2026-10-15:卡 02:02 已 resolved、verdict 已落账,
540
+ // verifier 子会话却持续写盘空转到 10:16 被主窗口手杀——8 小时空烧)=====
541
+ // 病根:一次性 run 的 settle 只关账(dispose 只释放本地 run 句柄,不停子会话的 agent loop)——
542
+ // verifier 调完 board_verdict 后 turn 未结束(继续探索/循环),会话持续空转烧 token。
543
+ // 一次性子代理「结算即销毁」是设计语义,这里补齐销毁的半步:结算落定后对该 run 的子会话补一枪
544
+ // interrupt(与 endContinuable 同款 authority 形状,fire-and-return 同步发信号即返回)。
545
+ // 挂载点取 settleRunRecord 末尾 = 所有一次性落定路径的唯一汇合点(settleRun 的
546
+ // completed/failed/incomplete、幽灵回收 reapGhostRecs 的收尾都经这里)——含事故原形
547
+ // 「board_verdict 落账 → 卡 resolved → rec 下轮被幽灵回收」那条(rec 在表外时 settleRun 根本跑不到)。
548
+ // 边界(零改动):hook 点位不在本卡范围;重复 interrupt 无害(取消信号幂等)。continuable 豁免经
549
+ // task-mvbeallq 收紧:原「continuable 即跳」过宽(无续跑位的残影借豁免续跑),现仅「续跑位真在
550
+ // 本 child 手上」才放行,其余 continuable 落定同样补停——收紧细则与竞态论证见函数头注释。
551
+ // 失败只 console.error:结算已落定,残留空转只影响 token 不影响正确性(与 terminate 的 killFail
552
+ // 告警分级不同——那是用户显式要杀,这里是收尾补强)。
553
+ async function stopSettledOneShot(sid, rec) {
554
+ if (!rec || !rec.id) return
555
+ if (rec.role !== 'verifier' && rec.role !== 'worker') return // 只对 verifier 与 worker 动手
556
+ var subagents = ctx.subagents
557
+ if (!subagents || typeof subagents.interrupt !== 'function') return // 老宿主/老测试桩无此能力:逐字不变
558
+ var parent = rootForSession(sid)
559
+ if (!parent) return // 无活 root 可授权(root 已死/重建窗口)→ 跳过,结算语义不受影响
560
+ // ===== continuable 豁免收紧(task-mvbeallq,残影治理①)=====
561
+ // 原闸门「rec.continuable 即跳」过宽:continuable 但**已无续跑位**的落定——completed(resumeTarget
562
+ // 只认超时/失败结局)/ noResume 落旗(reap 失败)/ 卡面最新 worker 留档已被更新的 run 取代
563
+ // (池故障后主窗口重派了新 Worker)——借豁免续跑就是残影(ontology 实证:旧子会话残影续跑 5 分钟
564
+ // 逐文件推进,与新 Worker 差点同批文件互踩)。interrupt 对 continuable 只打断 turn、不销毁会话
565
+ // (endContinuable 同款语义),补停安全。豁免只剩「续跑位真在本 child 手上」两种形态:
566
+ // ① 本卡活跃表已有接管**同一子会话**的新 rec——续跑在途,此刻打断会杀掉刚复活的新 turn。
567
+ // (续跑路径纪律:sendMessage 返回后**零 await**先登记 rec 再追加续跑留档,故新 turn 全程
568
+ // 被本形态覆盖;不同 id 的新 rec=fresh spawn 新 Worker,不豁免——旧残影照停。)
569
+ // ② 锁外新鲜快照上 resumeTarget 仍指向本 child——续跑位未被消费,下轮重派会冷复活它。
570
+ // (豁免判定必须读新鲜快照:结算刚关账,只有自己内存里的 rec 不足以判定留档次序——
571
+ // 幽灵回收路径上卡面可能已有更新的 worker 留档。)
572
+ // 两形态之外一律补停;快照读不到(t=null)按「无续跑位」处理——宁停勿留残影。
573
+ if (rec.continuable) {
574
+ var cur = runsFor(sid)[rec.taskId]
575
+ if (cur && String(cur.id) === String(rec.id)) return // ① 续跑在途(新 rec 接管同一子会话)
576
+ var t = null
577
+ try {
578
+ var dsnap = await rt(sid)
579
+ if (dsnap && Array.isArray(dsnap.tasks)) { for (var ti = 0; ti < dsnap.tasks.length; ti++) { if (dsnap.tasks[ti] && dsnap.tasks[ti].id === rec.taskId) { t = dsnap.tasks[ti]; break } } }
580
+ } catch (_) { t = null }
581
+ if (t && resumeTarget(t) === String(rec.id)) return // ② 续跑位在手:留存会话供冷复活
582
+ }
583
+ try {
584
+ subagents.interrupt(String(rec.id), { kind: 'ancestor', agent: parent })
585
+ } catch (e) {
586
+ console.error('[task-board] 一次性 run 结算后 interrupt 子会话失败(会话可能仍在空转,只影响 token)task ' + rec.taskId + ' child ' + rec.id + ':', String(e))
587
+ }
588
+ }
589
+
417
590
  async function settleRunRecord(sid, rec, opts) {
418
591
  // ① 结局落卡:failed → 有 err 记 timeout/error,无 err 记 incomplete(与一次性路径口径一致);
419
592
  // 成功 → completed。noResume 旗随 rec.resumeBlocked(interrupt 失败)落卡。
@@ -429,6 +602,11 @@ export function createDispatch(ctx, state, deps) {
429
602
  // ② settle 通道心跳(运行时健康自检):三件套各自幂等且各自 catch 永不抛,
430
603
  // 走到这里即「一次成功结算活动」——rpc 侧据此判定「结算通道 >30min 无活动」黄条。
431
604
  poolHealthFor(sid).settleLastOkAt = Date.now()
605
+ // ④ 结算≠停止根修(task-mv8wuznb):落定后主动 interrupt 子会话,堵「结算≠停止」空转洞。
606
+ // task-mvbeallq 起改 async(continuable 豁免收紧需读锁外快照算 resumeTarget)——interrupt 本身
607
+ // 仍是同步信号 fire-and-return;闸门与 try/catch 全在函数内/调用处,绝不阻断/改变结算语义;
608
+ // 续跑位在手的 continuable 与 hook 在函数内被原样放行(豁免边界见函数头注释)。
609
+ try { await stopSettledOneShot(sid, rec) } catch (e) { console.error('[task-board] 结算后补停子会话异常(不影响结算)task ' + rec.taskId + ':', String(e)) }
432
610
  }
433
611
 
434
612
  async function settleRun(sid, rec, res, err) {
@@ -696,8 +874,9 @@ export function createDispatch(ctx, state, deps) {
696
874
  // · 只认 continuable 且 role==='worker' 的活跃 rec:verifier/hook 是一次性 run。它们的 run.result
697
875
  // 落定后 settleRun 会照常收尾;手动终止路径**刻意**不关 run(保留 running = 不留续跑资格),此处不碰。
698
876
  // · 摘除活跃表项:任务已落定,这张卡不该再占 Worker 并发位;否则 poolCycle 的 activeW 会把它算成
699
- // 在跑的 Worker(幽灵占位)。子会话本身不销毁也不 interrupt——它与手动终止同源:失联的续跑资格
700
- // 留在卡上,之后真要重派走 tryResumeWorker 的 sendMessage 冷复活。
877
+ // 在跑的 Worker(幽灵占位)。子会话由 settleRunRecord 末尾的 stopSettledOneShot 补停
878
+ // (task-mvbeallq:completed 落定天然无续跑位——resumeTarget 只认超时/失败结局——「续跑豁免」
879
+ // 留下的就是残影续跑窗;补停只打断 turn 不销毁会话,与 endContinuable 同款语义)。
701
880
  // · 幂等:closeRunHistory 只关 outcome==='running' 的条目、accumulateRunUsage 认 usageRecorded 旗、
702
881
  // rec.disarm 幂等——重复上报 / 上报后 idle 事件补到,都不会二次关账或二次记账。
703
882
  // 老宿主/没接线的调用方:deps 里没有这个函数时 rpc 侧静默跳过,行为与本次改造前逐字一致。
@@ -828,7 +1007,15 @@ export function createDispatch(ctx, state, deps) {
828
1007
  // 失败原因落卡(截断 300):verifier 故障/输出无法判定的原因留在卡片上供排查
829
1008
  t.lastError = String(errText || 'verifier 未给出有效结论(输出格式异常)').slice(0, 300)
830
1009
  // 失败/空输出/无法判定:verifyRetries 计数,>=3 转人工验收(deliverable 已完成,是 verifier 故障不是任务故障)
831
- if (failed && rec.model) { badModels[modelKey(sid, rec.model)] = true; pushSysNote(sid, '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型') }
1010
+ if (failed && rec.model) {
1011
+ badModels[modelKey(sid, rec.model)] = true
1012
+ // 熔断文案分态(task-mv73ym2k,实证 n-mv6ezj6vary4):错误串含 UNKNOWN_MODEL = 模型 id 非法
1013
+ // (mistake 类:配置写错,模型无辜)——与「连续验收失败」(quality 类)区分,不归一堆。
1014
+ var modelIllegal = /UNKNOWN_MODEL/i.test(String(errText || ''))
1015
+ pushSysNote(sid, modelIllegal
1016
+ ? '模型 ' + rec.model + ' 配置非法(不在可用清单),已熔断回退父级模型——请经 list-models 选合法 id 重配'
1017
+ : '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型')
1018
+ }
832
1019
  t.verifyRetries = (t.verifyRetries || 0) + 1
833
1020
  if (t.verifyRetries >= 3) { t.escalation = { question: 'Verifier 连续 ' + t.verifyRetries + ' 次未能给出有效结论(' + (failed ? String(errText).slice(0, 150) : '输出格式异常') + ')。交付物已完成,请人工验收:看板详情页直接通过/驳回,或 task_verify 裁决。', at: new Date().toISOString(), by: 'system' }; ah(t, 'verifying', 'verifying', 'system', 'verifier 故障,转人工验收'); return { task: t, escalated: true } }
834
1021
  ah(t, 'verifying', 'verifying', 'system', 'verifier 未给出有效结论,重新排队审查 (' + t.verifyRetries + '/3)')
@@ -939,7 +1126,26 @@ export function createDispatch(ctx, state, deps) {
939
1126
  if (String(last.id) !== String(t.claimedBy)) continue // 卡上留档与占位不一致:状态可疑,不动
940
1127
  cands.push(t)
941
1128
  }
942
- if (!cands.length) return
1129
+ // ===== 孤儿一次性子会话补停(task-mv8wuznb ②,重启场景)=====
1130
+ // 卡已落定(resolved/cancelled/archived)而 verifier/一次性 worker 的 run 留档仍 running 无
1131
+ // endedAt:重启把内存 rec 清零后,结算收尾(stopSettledOneShot)的 interrupt 再也打不到它——
1132
+ // 若子会话仍活在宿主目录(下方 listChildren 命中),顺手 interrupt 停掉(「结算即销毁」的重启补课,
1133
+ // 实证:verifier 空转 8h 的会话若中途遇 host 重启,苏醒后照旧空烧)。
1134
+ // 只停会话、不动卡面:running 留档的关账归 sweepZombieRuns(每轮都跑,就排在本函数之后)。
1135
+ // continuable worker 豁免(留存会话供续跑是设计语义;在飞卡的 continuable 由上方候选①重建观测)。
1136
+ var orphans = []
1137
+ for (var oi = 0; oi < list.length; oi++) {
1138
+ var ot = list[oi]
1139
+ if (!ot || (ot.status !== 'resolved' && ot.status !== 'cancelled' && ot.status !== 'archived')) continue
1140
+ var ors = Array.isArray(ot.runs) ? ot.runs : []
1141
+ for (var oj = 0; oj < ors.length; oj++) {
1142
+ var orun = ors[oj]
1143
+ if (!orun || orun.outcome !== 'running' || orun.endedAt) continue
1144
+ if (orun.role !== 'verifier' && !(orun.role === 'worker' && orun.continuable !== true)) continue
1145
+ orphans.push(String(orun.id))
1146
+ }
1147
+ }
1148
+ if (!cands.length && !orphans.length) return
943
1149
  // 存活判定:listChildren 列出的就是「活的子会话」。带超时保护(查询挂死也不能拖住心跳)。
944
1150
  var kids = null
945
1151
  try {
@@ -999,6 +1205,22 @@ export function createDispatch(ctx, state, deps) {
999
1205
  }, true)
1000
1206
  } catch (e) { console.error('[task-board] reconcile 死会话回收失败:', String(e)) }
1001
1207
  }
1208
+ // 孤儿一次性子会话补停(task-mv8wuznb ②):只打「仍在活跃目录」的——不在目录 = 会话已死,
1209
+ // 杀无可杀(留档归 sweepZombieRuns 关账);无 interrupt 能力/无活 root → 静默跳过;
1210
+ // 单条失败只 console.error,不拖住本轮 reconcile 的其它动作。
1211
+ var stopped = 0
1212
+ if (orphans.length && typeof subagents.interrupt === 'function') {
1213
+ var oroot = rootForSession(sid)
1214
+ if (oroot) {
1215
+ for (var oi2 = 0; oi2 < orphans.length; oi2++) {
1216
+ var oid = orphans[oi2]
1217
+ if (!alive[oid]) continue
1218
+ try { subagents.interrupt(oid, { kind: 'ancestor', agent: oroot }); stopped++ }
1219
+ catch (e) { console.error('[task-board] reconcile 孤儿一次性子会话 interrupt 失败(' + oid + '):', String(e)) }
1220
+ }
1221
+ }
1222
+ }
1223
+ if (stopped) console.log('[task-board] 重启 reconcile(' + sid + '):补停落定卡孤儿一次性子会话 ' + stopped + ' 个')
1002
1224
  }
1003
1225
 
1004
1226
  // ===== 僵尸 run 留档清扫(task-mv22z61v):关账「落定卡上的 running 无 endedAt 留档」+ 清卡面认领位 =====
@@ -1352,6 +1574,10 @@ export function createDispatch(ctx, state, deps) {
1352
1574
  try {
1353
1575
  var rec = null
1354
1576
  var resumeFallback = ''
1577
+ // 落闸弃权旗(task-mv6c7cdm):闸门拒绝 ≠ spawn 失败——卡面已被 terminate 等路径在 spawn 在途
1578
+ // 期间重排(占位已清、回 pending),本次派发基于过期快照被锁内重验拦下。弃权时绝不做失败回收
1579
+ // 与「启动失败」误报,卡面留给下一轮 cycle 按现状重新决策。
1580
+ var spawnVetoed = false
1355
1581
  // ===== 重派优先走「冷复活续跑」(卡2)=====
1356
1582
  // 只在 Worker 角色 + workerContinuable 开(缺省)时尝试:命中「上次 continuable run 结局=超时/失败」
1357
1583
  // 的任务时 sendMessage 唤醒原 child(不 spawn 新会话)。不适用(null)或失败(fallback)都落到下面的
@@ -1360,11 +1586,12 @@ export function createDispatch(ctx, state, deps) {
1360
1586
  var rr = await tryResumeWorker(sid, sp.t)
1361
1587
  if (rr && rr.rec) rec = rr.rec
1362
1588
  else if (rr && rr.fallback) resumeFallback = rr.fallback
1589
+ else if (rr && rr.vetoed) spawnVetoed = true
1363
1590
  }
1364
1591
  // 续跑失败才回退 fresh spawn:**先把说明写进任务消息再 spawn**——新 Worker 的首条 prompt 由
1365
1592
  // buildWorkerPrompt 从任务对象组装(含 messages),这样它自己就能看到「上一轮续跑为何没接上」,
1366
1593
  // 而不是只留在详情页给人事后翻账。返回的 t 是写盘后那份,直接用它 spawn。
1367
- if (!rec && resumeFallback) {
1594
+ if (!rec && resumeFallback && !spawnVetoed) {
1368
1595
  try {
1369
1596
  var noted = await mutateLocked(sid, function (d) {
1370
1597
  var t2 = d.tasks.find(function (x) { return x.id === sp.t.id })
@@ -1376,16 +1603,29 @@ export function createDispatch(ctx, state, deps) {
1376
1603
  if (noted && noted.t) sp.t = noted.t
1377
1604
  } catch (e) { console.error('[task-board] 续跑回退说明落卡失败 (task ' + sp.t.id + '):', String(e)) }
1378
1605
  }
1379
- if (!rec) rec = await spawnOneShot(sid, sp.t, sp.role)
1606
+ if (!rec && !spawnVetoed) {
1607
+ var sr = await spawnOneShot(sid, sp.t, sp.role)
1608
+ if (sr && sr.vetoed) spawnVetoed = true // 落闸弃权(fresh spawn 在途被翻卡)
1609
+ else rec = sr
1610
+ }
1380
1611
  if (rec) {
1381
1612
  // 成功派发计数(运行时健康自检):续跑命中与 fresh spawn 都算一次成功派发
1382
1613
  var __ph = poolHealthFor(sid); __ph.dispatchOk = (__ph.dispatchOk || 0) + 1; __ph.lastDispatchAt = Date.now()
1383
1614
  // claim 占位换成真实 run id;verifier/hook-post run 单独记(claimedBy 保留 worker 的,供详情页跳转会话)
1384
- await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t) { if (sp.role === 'worker' && t.claimedBy === 'spawn-pending') t.claimedBy = String(rec.id); if ((sp.role === 'verifier' || sp.role === 'hook-post') && t.verifierRun === 'spawn-pending') { t.verifierRun = String(rec.id); t.verifierRunAt = new Date().toISOString() }; if (sp.role === 'hook-pre' && t.hooks && t.hooks.pre) t.hooks.pre.runId = String(rec.id) }; return t }, true)
1615
+ // 零改动不写盘(task-mv6c7cdm):落闸重验把 rec 登记提前到闸门之前后,续跑轮若撞上快速 idle
1616
+ // 结算(settle 已在闸门窗口内把卡翻走),本 swap 的占位守卫必然失败——此时写盘纯属在飞噪音
1617
+ // (实测:测试 cleanup 拆除临时目录后这笔迟到的 wt 以 ENOENT 刷屏),不变就返回 null。
1618
+ await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (!t) return null; var swapped = false; if (sp.role === 'worker' && t.claimedBy === 'spawn-pending') { t.claimedBy = String(rec.id); swapped = true }; if ((sp.role === 'verifier' || sp.role === 'hook-post') && t.verifierRun === 'spawn-pending') { t.verifierRun = String(rec.id); t.verifierRunAt = new Date().toISOString(); swapped = true }; if (sp.role === 'hook-pre' && t.hooks && t.hooks.pre && t.hooks.pre.runId !== String(rec.id)) { t.hooks.pre.runId = String(rec.id); swapped = true }; return swapped ? t : null }, true)
1385
1619
  // 派发即回执:spawn 真成功后才入队(占位阶段失败不通知);经 deps 注入,未注入静默跳过(老 host 兼容)
1386
1620
  // 回执开关(设置区「通知」):notifyDispatch=false → 派发回执整条跳过(spawn 照常,只闭嘴);
1387
1621
  // 闸门读本轮 poolCycle 已取的 cfg 快照 c(无额外读盘),老看板缺字段 → cfg 归一为 true。
1388
1622
  if (c.notifyDispatch !== false && typeof notifyDispatched === 'function') notifyDispatched(sid, sp.t, sp.role)
1623
+ } else if (spawnVetoed) {
1624
+ // 落闸弃权(task-mv6c7cdm):卡面已被 terminate 等路径重排,本轮 claim 作废——
1625
+ // 占位由翻卡那笔写清掉、被弃的子会话已由闸门 interrupt/dispose 停掉,这里刻意不再动卡面、
1626
+ // 不发「启动失败」误报(闸门函数内已各留一行 console 痕迹),等下一轮 cycle 重新决策。
1627
+ // dependsOn 后补导致的否决(task-mv9yupm4):占位由闸门调用方的 revertDepsVeto 条件化
1628
+ // 翻回 pending(依赖未满足 → pickDispatch 门控不派;依赖落定那笔写 kick 后自动重派)。
1389
1629
  } else if (sp.role === 'worker') {
1390
1630
  // spawn 失败 → 回 pending
1391
1631
  await releaseSpawnPlaceholder(sid, sp, 'spawn 失败,回收重新排队')
@@ -1451,5 +1691,7 @@ export function createDispatch(ctx, state, deps) {
1451
1691
  // 在并行 spawn 下存在认领错包的潜伏 bug,一并消灭。
1452
1692
  }
1453
1693
 
1454
- return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory }
1694
+ // resumeTarget 一并透出(task-mv6c7cbo):rpc 侧 doIntervene 在无活跃 rec 时靠它细分
1695
+ // 「续跑窗口 / 真无 run」两态(纯读 t.runs 零 IO,同一函数两处复用,不另写第二套口径)。
1696
+ return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory, resumeTarget: resumeTarget }
1455
1697
  }
package/lib/health.mjs CHANGED
@@ -216,6 +216,15 @@ export function computeRuntimeHealthHints(board, rt, opts) {
216
216
  out.hints.push({ level: 'err', text: '迟到驳回:' + String(lr.taskId) + ' 你批早了但 Verifier 验出红的' })
217
217
  }
218
218
 
219
+ // --- ⑥ 终止未生效可见化(terminate 假杀根修②,task-mv8jfu1v,事故实证 n-mv8cfndmvfcv)---
220
+ // doTerminate 的 interrupt 抛错/宿主无中断能力/无 rec 补杀核验未过 → rt.terminateFailNote
221
+ // (sticky 不 TTL——host 重启才清)。与 lateRejectNote 同款 err 级:终止假杀 = 僵尸 Worker
222
+ // 继续写盘的数据安全事故(ae9cf32a 被 terminate 后继续写盘 8 分钟),必须持续亮到人来补刀。
223
+ var tf = rt.terminateFailNote
224
+ if (tf && typeof tf === 'object' && tf.taskId) {
225
+ out.hints.push({ level: 'err', text: '🔴 终止未生效:' + String(tf.taskId) + ' 的 Worker 子会话(' + String(tf.childId || '') + ')可能仍在跑——请用 interrupt_agent 补刀' })
226
+ }
227
+
219
228
  if (alive) {
220
229
  // --- ① 派发循环心跳:有可派卡 + 池有空位 + 上次成功轮 >5min ---
221
230
  // poolLastOkAt 缺失时退到 bornAt(首次见到本板的时刻):host 重启后心跳全断也能在 5min 宽限后亮条,