dsh-agent-board 1.7.8 → 1.8.0
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 +19 -8
- package/index.mjs +1 -0
- package/lib/cleanup.mjs +256 -0
- package/lib/client/dashboard.js +139 -11
- package/lib/client/kernel.js +32 -7
- package/lib/client/task-detail.js +13 -7
- package/lib/client.js +184 -25
- package/lib/core.mjs +24 -4
- package/lib/dispatch.mjs +187 -5
- package/lib/rpc.mjs +173 -26
- package/lib/usage.mjs +13 -4
- package/package.json +1 -1
- package/test/cleanup.test.mjs +341 -0
- package/test/conditional-get-tasks.test.mjs +96 -0
- package/test/core.test.mjs +410 -7
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/integration.test.mjs +153 -0
- package/test/scoreboard-client.test.mjs +15 -9
- package/test/scoreboard.test.mjs +44 -4
- package/test/ui-zoom-client.test.mjs +76 -0
package/lib/dispatch.mjs
CHANGED
|
@@ -174,6 +174,34 @@ 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
|
+
// · 续跑追加验:锁内重算的 resumeTarget 仍指向同一个 childId(terminate 命中活跃 rec 时会把
|
|
186
|
+
// 留档关成 'terminated',续跑资格随之失效——锁内重算天然覆盖这条)。
|
|
187
|
+
// 用法契约:spawn 返回后**零 await 先登记 rec**(保住事件可见性既有纪律),再过本闸门;
|
|
188
|
+
// 闸门否决时由调用方逆向摘干净(settled 旗 + 摘 rec + 停子会话)。这样 terminate 只剩两类落点:
|
|
189
|
+
// 落在 spawn 在途期(当时无 rec,闸门读锁内快照发现卡面已翻走 → 否决)/ 落在登记之后(命中
|
|
190
|
+
// 活跃 rec,走 terminate 自己的正常 interrupt+关账路径)——不再存在「在途 spawn 看不见」的
|
|
191
|
+
// 第三类窗口。纯验证:回调恒返回 null → 不写盘不 kick(mutateLocked 契约);
|
|
192
|
+
// 闸门自身异常按弃权处理(宁停勿双——占位遗留由 2min 占位超时回收兜底,下轮重派)。
|
|
193
|
+
async function spawnGateOk(sid, taskId, resumeId) {
|
|
194
|
+
var ok = false
|
|
195
|
+
try {
|
|
196
|
+
await mutateLocked(sid, function (d) {
|
|
197
|
+
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
198
|
+
ok = !!(t && t.status === 'in-progress' && t.claimedBy === 'spawn-pending' && (!resumeId || resumeTarget(t) === resumeId))
|
|
199
|
+
return null
|
|
200
|
+
}, true)
|
|
201
|
+
} catch (e) { console.error('[task-board] spawn 闸门重验异常(按弃权处理,宁停勿双)task ' + taskId + ':', String(e)); ok = false }
|
|
202
|
+
return ok
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
async function spawnOneShot(sid, t, role) {
|
|
178
206
|
var subagents = ctx.subagents; if (!subagents) return null
|
|
179
207
|
// 兜底保留(真异常时有用):poolCycle 入口已有 root 存活早闸门,正常派发路径不会到达这里;
|
|
@@ -262,9 +290,33 @@ export function createDispatch(ctx, state, deps) {
|
|
|
262
290
|
var rec = useContinuable
|
|
263
291
|
? { id: childId, childId: childId, continuable: true, ran: false, run: null, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
|
|
264
292
|
: { id: String(run.id), run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
|
|
293
|
+
// ⚠️ spawn 返回 → 登记 rec 必须**零 await**(既有纪律):continuable 子会话的 running→idle 事件
|
|
294
|
+
// 随时可能到达,事件通道必须马上能在活跃表里看到它——否则 idle 被 rec.ran 守卫挡下,结算干等硬超时。
|
|
265
295
|
runsFor(sid)[t.id] = rec
|
|
266
296
|
if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
|
|
267
297
|
dispatchedEver[sid][String(rec.id)] = true
|
|
298
|
+
// ===== 落闸锁内重验(task-mv6c7cdm,仅 worker 角色;与续跑路径同一闸门 spawnGateOk)=====
|
|
299
|
+
// spawn 在途窗口内卡面可能已被 terminate 等路径翻走(占位清、回 pending)——此时本次派发基于
|
|
300
|
+
// 过期快照,必须弃权:settled 旗堵迟到结算 + 摘 rec + 停掉刚建的子会话(continuable=interrupt
|
|
301
|
+
// 留存口径与 terminate 一致;一次性=dispose),不落 run 留档——它在卡面从未存在过,卡面由
|
|
302
|
+
// terminate 那笔写重排(pending),下一轮 cycle 按现状重新决策。
|
|
303
|
+
// 为什么登记在闸门之前:闸门是持锁慢操作(锁内重读新鲜快照),若夹在「spawn 返回」与「登记」
|
|
304
|
+
// 之间会重新撕开事件可见性窗口——登记先行保住既有纪律,闸门否决时再逆向摘干净即可。
|
|
305
|
+
// verifier/hook 不验:verifier 的手动派发通道(dispatch-task)不设占位,验了会误杀正常手动派发;
|
|
306
|
+
// hook 的占位语义不同(state 机幂等)——二者都不在本次竞态实证范围内。
|
|
307
|
+
if (role === 'worker') {
|
|
308
|
+
var gateOk = await spawnGateOk(sid, t.id, '')
|
|
309
|
+
if (!gateOk) {
|
|
310
|
+
rec.settled = true // 先立旗:迟到的 idle 结算与超时臂一并放行空转
|
|
311
|
+
if (runsFor(sid)[t.id] === rec) delete runsFor(sid)[t.id]
|
|
312
|
+
try {
|
|
313
|
+
if (useContinuable) { if (typeof subagents.interrupt === 'function') subagents.interrupt(childId, { kind: 'ancestor', agent: parent }) }
|
|
314
|
+
else if (run && typeof run.dispose === 'function') await run.dispose()
|
|
315
|
+
} catch (_) {}
|
|
316
|
+
console.error('[task-board] spawn 落闸被锁内重验拒绝(卡面已变更,本次派发弃权)task ' + t.id + ' role ' + role)
|
|
317
|
+
return { vetoed: true }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
268
320
|
// 历史会话留档:每次派发都追加一条 {role,id,at,model},任务完成后仍可回看
|
|
269
321
|
// 各阶段(含重试的第 1/2/3 次 Worker)会话——否则 claimedBy/verifierRun 只留最后一次。
|
|
270
322
|
// **必须 await**(task-muwkhqf8):上报通道的收尾(settleReportedRun → closeRunHistory)要求在
|
|
@@ -396,10 +448,49 @@ export function createDispatch(ctx, state, deps) {
|
|
|
396
448
|
// NOT_RESUMABLE / 邻接校验失败 / 任何错:一律回退 fresh spawn(本函数不抛,调用方按 rec 为 null 处理)。
|
|
397
449
|
return { fallback: String(e) }
|
|
398
450
|
}
|
|
451
|
+
// ⚠️ sendMessage 返回 → 登记 rec 必须**零 await**(既有纪律):冷复活子会话的 running→idle
|
|
452
|
+
// 随时可能到达,事件通道必须马上能在活跃表里看到它——否则 idle 被 rec.ran 守卫挡下,结算干等硬超时。
|
|
399
453
|
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
454
|
runsFor(sid)[t.id] = rec
|
|
401
455
|
if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
|
|
402
456
|
dispatchedEver[sid][childId] = true
|
|
457
|
+
// ===== 落闸锁内重验(task-mv6c7cdm):sendMessage 冷复活是慢操作,在途期间卡面可能已被
|
|
458
|
+
// terminate 翻走(它看不到本次在途续跑——当时活跃表无 rec,interrupt 打不到、关账轮不到)。
|
|
459
|
+
// 闸门以锁内新鲜快照重验:本轮占位仍在 + 锁内重算 resumeTarget 仍指向同一 childId。
|
|
460
|
+
// 拒绝即弃权三件套:
|
|
461
|
+
// ① settled 旗 + 摘 rec——迟到的 idle 结算与本轮派发痕迹一并失效;
|
|
462
|
+
// ② interrupt 刚复活的子会话——否则它脱离观测空跑烧上下文(实证 f3f9a66f 空烧 64k tok);
|
|
463
|
+
// ③ 旧 run 留档立 noResume 旗——terminate 命中活跃 rec 时 closeRunHistory('terminated') 会让
|
|
464
|
+
// 续跑资格失效;这里留档早已落定关不动,补 noResume 达同一语义(用户 terminate 就是要换掉它),
|
|
465
|
+
// 下一轮 cycle 走 fresh spawn 新 Worker 而不是再次复活它。
|
|
466
|
+
// 不落的只有续跑留档——本次派发在卡面从未存在过。
|
|
467
|
+
// 为什么登记在闸门之前:闸门是持锁慢操作,夹在「sendMessage 返回」与「登记」之间会重新撕开
|
|
468
|
+
// 事件可见性窗口——登记先行保住既有纪律,闸门否决时再逆向摘干净(与 spawnOneShot 同款)。
|
|
469
|
+
var gateOk = await spawnGateOk(sid, t.id, childId)
|
|
470
|
+
if (!gateOk) {
|
|
471
|
+
rec.settled = true // 先立旗:迟到的 idle 结算与超时臂一并放行空转
|
|
472
|
+
if (runsFor(sid)[t.id] === rec) delete runsFor(sid)[t.id]
|
|
473
|
+
try { if (typeof subagents.interrupt === 'function') subagents.interrupt(childId, { kind: 'ancestor', agent: parent }) } catch (_) {}
|
|
474
|
+
try {
|
|
475
|
+
await mutateLocked(sid, function (d) {
|
|
476
|
+
var t2 = d.tasks.find(function (x) { return x.id === t.id })
|
|
477
|
+
if (!t2 || !Array.isArray(t2.runs)) return null
|
|
478
|
+
for (var ri = t2.runs.length - 1; ri >= 0; ri--) {
|
|
479
|
+
var r2 = t2.runs[ri]
|
|
480
|
+
if (!r2 || r2.role !== 'worker') continue
|
|
481
|
+
if (String(r2.id) !== String(childId)) break // 最新 worker 留档不是它 → 续跑资格早已易主,不动
|
|
482
|
+
if (r2.noResume === true) return null
|
|
483
|
+
if (r2.outcome !== 'timeout/error' && r2.outcome !== 'incomplete') return null // 仍 running 等形态归结算/终止路径管
|
|
484
|
+
r2.noResume = true
|
|
485
|
+
ah(t2, t2.status, t2.status, 'system', '派发落闸重验未通过(terminate 竞态):续跑弃权,旧子会话不再续跑')
|
|
486
|
+
return { ok: true }
|
|
487
|
+
}
|
|
488
|
+
return null
|
|
489
|
+
}, true)
|
|
490
|
+
} catch (_) {}
|
|
491
|
+
console.error('[task-board] 续跑落闸被锁内重验拒绝(terminate 竞态,已弃权并 interrupt 复活子会话)task ' + t.id + ' child ' + childId)
|
|
492
|
+
return { vetoed: true }
|
|
493
|
+
}
|
|
403
494
|
armTimeouts(sid, rec, t, c)
|
|
404
495
|
// runs[] 追加一条**续跑记录**(role:'worker', id:childId 与原 run 同 id=同一会话, resume:true):
|
|
405
496
|
// 详情页可区分首派/续跑,且它成为续跑资格判定的最新依据。
|
|
@@ -828,7 +919,15 @@ export function createDispatch(ctx, state, deps) {
|
|
|
828
919
|
// 失败原因落卡(截断 300):verifier 故障/输出无法判定的原因留在卡片上供排查
|
|
829
920
|
t.lastError = String(errText || 'verifier 未给出有效结论(输出格式异常)').slice(0, 300)
|
|
830
921
|
// 失败/空输出/无法判定:verifyRetries 计数,>=3 转人工验收(deliverable 已完成,是 verifier 故障不是任务故障)
|
|
831
|
-
if (failed && rec.model) {
|
|
922
|
+
if (failed && rec.model) {
|
|
923
|
+
badModels[modelKey(sid, rec.model)] = true
|
|
924
|
+
// 熔断文案分态(task-mv73ym2k,实证 n-mv6ezj6vary4):错误串含 UNKNOWN_MODEL = 模型 id 非法
|
|
925
|
+
// (mistake 类:配置写错,模型无辜)——与「连续验收失败」(quality 类)区分,不归一堆。
|
|
926
|
+
var modelIllegal = /UNKNOWN_MODEL/i.test(String(errText || ''))
|
|
927
|
+
pushSysNote(sid, modelIllegal
|
|
928
|
+
? '模型 ' + rec.model + ' 配置非法(不在可用清单),已熔断回退父级模型——请经 list-models 选合法 id 重配'
|
|
929
|
+
: '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型')
|
|
930
|
+
}
|
|
832
931
|
t.verifyRetries = (t.verifyRetries || 0) + 1
|
|
833
932
|
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
933
|
ah(t, 'verifying', 'verifying', 'system', 'verifier 未给出有效结论,重新排队审查 (' + t.verifyRetries + '/3)')
|
|
@@ -1001,6 +1100,67 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1001
1100
|
}
|
|
1002
1101
|
}
|
|
1003
1102
|
|
|
1103
|
+
// ===== 僵尸 run 留档清扫(task-mv22z61v):关账「落定卡上的 running 无 endedAt 留档」+ 清卡面认领位 =====
|
|
1104
|
+
// 事故(用户实证):0.4.1/0.4.5 时代老卡,宿主多次重启后 run 留档停在 outcome:'running' 无 endedAt +
|
|
1105
|
+
// 卡面 verifierRun/claimedBy 残留指向死会话。归档门禁 activeRunOf 的卡面兜底(liveEntry 认 claimedBy/
|
|
1106
|
+
// verifierRun 匹配的 running 留档)把它们误判活跃 → archive 永久拒。mv1pqy27 的 doTerminate 状态无关
|
|
1107
|
+
// 清理只覆盖「rec 在内存表」场景;重启后 rec 消失 + 卡已落定的组合落入盲区(resolved 无分支,什么都不清),
|
|
1108
|
+
// 且没有任何工具能关一条无 rec 的 run 留档本体。
|
|
1109
|
+
// 修法:扫描本板卡片的 runs[],凡 outcome==='running' 且无 endedAt 且该 run id 不在内存活跃表(runsFor)
|
|
1110
|
+
// 且所属卡已落定(resolved/cancelled/archived)→ 关账(endedAt=now,outcome 改 'stale-closed',注记
|
|
1111
|
+
// note='host 重启僵尸留档清扫')+ 清对应卡面认领位(verifierRun/claimedBy 若指向它)。
|
|
1112
|
+
// 边界(宁少勿多):
|
|
1113
|
+
// ① 只对落定卡做——在飞卡的 running 留档是 reconcile / 结算通道的辖区,别越界;
|
|
1114
|
+
// ② 无活跃 rec 才扫——内存表里仍活着(!settled)的 run 留给结算通道 / reapGhostRecs 关账(它们用
|
|
1115
|
+
// 'completed'/'timeout-error' 等真结局,而非这里的 'stale-closed',口径更准);
|
|
1116
|
+
// ③ 幂等——关账后 outcome!=running,二次运行零改动;回调无改动时返回 null 不写盘(沿用空闲快进的
|
|
1117
|
+
// 「不写盘」纪律,安静板不因清扫产生 15s 心跳写盘);
|
|
1118
|
+
// ④ 留痕——history 行 + console 一行(有产出时)。
|
|
1119
|
+
// 'stale-closed' 是既有枚举之外的新结局值:详情页/聚合端对未知 outcome 一律按「已结束(有 endedAt)」
|
|
1120
|
+
// 兜底展示,不会把它误判成 running(running 的判据是 outcome==='running' 且无 endedAt,二者已同时改写)。
|
|
1121
|
+
async function sweepZombieRuns(sid, snap) {
|
|
1122
|
+
if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0 // 瞬时读盘失败/空板:跳过,不写盘
|
|
1123
|
+
// 预扫快照:有候选(落定卡 + running 无 endedAt 留档)才进锁写;否则零 mutateLocked 调用——
|
|
1124
|
+
// 空板/安静板走空闲快进「不写盘」的纪律不受清扫破坏(每 15s 心跳也不能因清扫多出一次写盘调用)。
|
|
1125
|
+
var hasCandidate = false
|
|
1126
|
+
for (var pi = 0; pi < snap.tasks.length && !hasCandidate; pi++) {
|
|
1127
|
+
var pt = snap.tasks[pi]
|
|
1128
|
+
if (!pt || (pt.status !== 'resolved' && pt.status !== 'cancelled' && pt.status !== 'archived')) continue
|
|
1129
|
+
var prs = Array.isArray(pt.runs) ? pt.runs : []
|
|
1130
|
+
for (var pj = 0; pj < prs.length; pj++) { if (prs[pj] && prs[pj].outcome === 'running' && !prs[pj].endedAt) { hasCandidate = true; break } }
|
|
1131
|
+
}
|
|
1132
|
+
if (!hasCandidate) return 0
|
|
1133
|
+
var runs = runsFor(sid)
|
|
1134
|
+
var swept = []
|
|
1135
|
+
await mutateLocked(sid, function (d) {
|
|
1136
|
+
if (!d || !Array.isArray(d.tasks)) return null
|
|
1137
|
+
var any = false
|
|
1138
|
+
d.tasks.forEach(function (t) {
|
|
1139
|
+
if (!t) return
|
|
1140
|
+
if (t.status !== 'resolved' && t.status !== 'cancelled' && t.status !== 'archived') return // 只清扫已落定卡
|
|
1141
|
+
if (!Array.isArray(t.runs)) return
|
|
1142
|
+
var cardChanged = false
|
|
1143
|
+
t.runs.forEach(function (r) {
|
|
1144
|
+
if (!r || r.outcome !== 'running' || r.endedAt) return // 只关「还开着且无 endedAt」的条目
|
|
1145
|
+
var rid = String(r.id)
|
|
1146
|
+
var rec = runs[t.id]
|
|
1147
|
+
if (rec && !rec.settled && String(rec.id) === rid) return // 内存活跃表里还活着 → 结算通道辖区,不扫
|
|
1148
|
+
r.outcome = 'stale-closed'
|
|
1149
|
+
r.endedAt = new Date().toISOString()
|
|
1150
|
+
r.note = 'host 重启僵尸留档清扫'
|
|
1151
|
+
if (t.verifierRun === rid) { t.verifierRun = null; delete t.verifierRunAt }
|
|
1152
|
+
if (t.claimedBy === rid) { t.claimedBy = null; t.claimedAt = null }
|
|
1153
|
+
swept.push(t.id + '/' + rid + '/' + (r.role || '?'))
|
|
1154
|
+
cardChanged = true
|
|
1155
|
+
})
|
|
1156
|
+
if (cardChanged) { ah(t, t.status, t.status, 'system', '僵尸 run 留档已关账(host 重启清扫)'); any = true }
|
|
1157
|
+
})
|
|
1158
|
+
return any ? d : null // 无改动返回 null → 不写盘(安静板不因清扫产生心跳写盘)
|
|
1159
|
+
}, true) // skipKick:本轮已是 poolCycle,无需再踢
|
|
1160
|
+
if (swept.length) console.log('[task-board] 僵尸 run 留档清扫 ' + swept.length + ' 条:' + swept.join(', '))
|
|
1161
|
+
return swept.length
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1004
1164
|
// ===== 幽灵占位回收(池冻结根修,task-muwlepg6)=====
|
|
1005
1165
|
// 病根(活体实证):活跃表项(rec)的**唯一**摘除点是各结算通道(事件通道 settleRun /
|
|
1006
1166
|
// 上报通道 settleReportedRun / 手动终止)。任何一条漏走(worker 经 board_report 完成但当时
|
|
@@ -1112,6 +1272,10 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1112
1272
|
// 位置取舍:root 闸门必须是第一件事(无活 root 的板零 IO),所以本段只能排在早退闸门与 rt 读盘之后;
|
|
1113
1273
|
// 而重建出来的 rec 仍在本轮被读到(活跃计数 / 池状态快照都在下面才求值),语义不受影响。
|
|
1114
1274
|
try { await reconcileRoot(sid) } catch (e) { console.error('[task-board] reconcile 失败:', String(e)) }
|
|
1275
|
+
// 僵尸 run 留档清扫(task-mv22z61v):位置在 reconcile 之后、幽灵回收与空闲快进之前。
|
|
1276
|
+
// 落定卡的僵尸留档无活跃 rec,reconcile(只在飞卡)/reapGhostRecs(只清内存表项)都管不到它,
|
|
1277
|
+
// 而它若留在 running 会卡死 archive;安静板(只有已落定卡)会走空闲快进早退,清扫必须排在其前才能兜到。
|
|
1278
|
+
try { await sweepZombieRuns(sid, snap) } catch (e) { console.error('[task-board] 僵尸留档清扫失败:', String(e)) }
|
|
1115
1279
|
// 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
|
|
1116
1280
|
// 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
|
|
1117
1281
|
// 故有回收时重读一次快照——否则下面空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(写盘已归一进锁)。
|
|
@@ -1287,6 +1451,10 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1287
1451
|
try {
|
|
1288
1452
|
var rec = null
|
|
1289
1453
|
var resumeFallback = ''
|
|
1454
|
+
// 落闸弃权旗(task-mv6c7cdm):闸门拒绝 ≠ spawn 失败——卡面已被 terminate 等路径在 spawn 在途
|
|
1455
|
+
// 期间重排(占位已清、回 pending),本次派发基于过期快照被锁内重验拦下。弃权时绝不做失败回收
|
|
1456
|
+
// 与「启动失败」误报,卡面留给下一轮 cycle 按现状重新决策。
|
|
1457
|
+
var spawnVetoed = false
|
|
1290
1458
|
// ===== 重派优先走「冷复活续跑」(卡2)=====
|
|
1291
1459
|
// 只在 Worker 角色 + workerContinuable 开(缺省)时尝试:命中「上次 continuable run 结局=超时/失败」
|
|
1292
1460
|
// 的任务时 sendMessage 唤醒原 child(不 spawn 新会话)。不适用(null)或失败(fallback)都落到下面的
|
|
@@ -1295,11 +1463,12 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1295
1463
|
var rr = await tryResumeWorker(sid, sp.t)
|
|
1296
1464
|
if (rr && rr.rec) rec = rr.rec
|
|
1297
1465
|
else if (rr && rr.fallback) resumeFallback = rr.fallback
|
|
1466
|
+
else if (rr && rr.vetoed) spawnVetoed = true
|
|
1298
1467
|
}
|
|
1299
1468
|
// 续跑失败才回退 fresh spawn:**先把说明写进任务消息再 spawn**——新 Worker 的首条 prompt 由
|
|
1300
1469
|
// buildWorkerPrompt 从任务对象组装(含 messages),这样它自己就能看到「上一轮续跑为何没接上」,
|
|
1301
1470
|
// 而不是只留在详情页给人事后翻账。返回的 t 是写盘后那份,直接用它 spawn。
|
|
1302
|
-
if (!rec && resumeFallback) {
|
|
1471
|
+
if (!rec && resumeFallback && !spawnVetoed) {
|
|
1303
1472
|
try {
|
|
1304
1473
|
var noted = await mutateLocked(sid, function (d) {
|
|
1305
1474
|
var t2 = d.tasks.find(function (x) { return x.id === sp.t.id })
|
|
@@ -1311,16 +1480,27 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1311
1480
|
if (noted && noted.t) sp.t = noted.t
|
|
1312
1481
|
} catch (e) { console.error('[task-board] 续跑回退说明落卡失败 (task ' + sp.t.id + '):', String(e)) }
|
|
1313
1482
|
}
|
|
1314
|
-
if (!rec
|
|
1483
|
+
if (!rec && !spawnVetoed) {
|
|
1484
|
+
var sr = await spawnOneShot(sid, sp.t, sp.role)
|
|
1485
|
+
if (sr && sr.vetoed) spawnVetoed = true // 落闸弃权(fresh spawn 在途被翻卡)
|
|
1486
|
+
else rec = sr
|
|
1487
|
+
}
|
|
1315
1488
|
if (rec) {
|
|
1316
1489
|
// 成功派发计数(运行时健康自检):续跑命中与 fresh spawn 都算一次成功派发
|
|
1317
1490
|
var __ph = poolHealthFor(sid); __ph.dispatchOk = (__ph.dispatchOk || 0) + 1; __ph.lastDispatchAt = Date.now()
|
|
1318
1491
|
// claim 占位换成真实 run id;verifier/hook-post run 单独记(claimedBy 保留 worker 的,供详情页跳转会话)
|
|
1319
|
-
|
|
1492
|
+
// 零改动不写盘(task-mv6c7cdm):落闸重验把 rec 登记提前到闸门之前后,续跑轮若撞上快速 idle
|
|
1493
|
+
// 结算(settle 已在闸门窗口内把卡翻走),本 swap 的占位守卫必然失败——此时写盘纯属在飞噪音
|
|
1494
|
+
// (实测:测试 cleanup 拆除临时目录后这笔迟到的 wt 以 ENOENT 刷屏),不变就返回 null。
|
|
1495
|
+
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)
|
|
1320
1496
|
// 派发即回执:spawn 真成功后才入队(占位阶段失败不通知);经 deps 注入,未注入静默跳过(老 host 兼容)
|
|
1321
1497
|
// 回执开关(设置区「通知」):notifyDispatch=false → 派发回执整条跳过(spawn 照常,只闭嘴);
|
|
1322
1498
|
// 闸门读本轮 poolCycle 已取的 cfg 快照 c(无额外读盘),老看板缺字段 → cfg 归一为 true。
|
|
1323
1499
|
if (c.notifyDispatch !== false && typeof notifyDispatched === 'function') notifyDispatched(sid, sp.t, sp.role)
|
|
1500
|
+
} else if (spawnVetoed) {
|
|
1501
|
+
// 落闸弃权(task-mv6c7cdm):卡面已被 terminate 等路径重排,本轮 claim 作废——
|
|
1502
|
+
// 占位由翻卡那笔写清掉、被弃的子会话已由闸门 interrupt/dispose 停掉,这里刻意不再动卡面、
|
|
1503
|
+
// 不发「启动失败」误报(闸门函数内已各留一行 console 痕迹),等下一轮 cycle 重新决策。
|
|
1324
1504
|
} else if (sp.role === 'worker') {
|
|
1325
1505
|
// spawn 失败 → 回 pending
|
|
1326
1506
|
await releaseSpawnPlaceholder(sid, sp, 'spawn 失败,回收重新排队')
|
|
@@ -1386,5 +1566,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1386
1566
|
// 在并行 spawn 下存在认领错包的潜伏 bug,一并消灭。
|
|
1387
1567
|
}
|
|
1388
1568
|
|
|
1389
|
-
|
|
1569
|
+
// resumeTarget 一并透出(task-mv6c7cbo):rpc 侧 doIntervene 在无活跃 rec 时靠它细分
|
|
1570
|
+
// 「续跑窗口 / 真无 run」两态(纯读 t.runs 零 IO,同一函数两处复用,不另写第二套口径)。
|
|
1571
|
+
return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory, resumeTarget: resumeTarget }
|
|
1390
1572
|
}
|