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/README.md +10 -7
- package/index.mjs +1 -0
- package/lib/client/dashboard.js +18 -11
- package/lib/client.js +18 -11
- package/lib/core.mjs +60 -3
- package/lib/dispatch.mjs +251 -9
- package/lib/health.mjs +9 -0
- package/lib/rpc.mjs +206 -23
- package/lib/usage.mjs +13 -4
- package/package.json +1 -1
- package/test/core.test.mjs +478 -6
- package/test/integration.test.mjs +258 -0
- package/test/runtime-health.test.mjs +22 -0
- package/test/scoreboard-client.test.mjs +15 -9
- package/test/scoreboard.test.mjs +44 -4
- package/test/settle-stop-interrupt.test.mjs +282 -0
- package/test/verify-timing.test.mjs +127 -2
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
|
|
700
|
-
//
|
|
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) {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 宽限后亮条,
|