dsh-agent-board 1.8.0 → 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 +4 -3
- package/lib/client/dashboard.js +7 -3
- package/lib/client.js +7 -3
- package/lib/core.mjs +45 -1
- package/lib/dispatch.mjs +132 -7
- package/lib/health.mjs +9 -0
- package/lib/rpc.mjs +136 -15
- package/package.json +1 -1
- package/test/core.test.mjs +220 -5
- package/test/integration.test.mjs +106 -1
- package/test/runtime-health.test.mjs +22 -0
- package/test/settle-stop-interrupt.test.mjs +282 -0
- package/test/verify-timing.test.mjs +127 -2
package/README.md
CHANGED
|
@@ -113,13 +113,13 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
113
113
|
|
|
114
114
|
## 功能总览
|
|
115
115
|
|
|
116
|
-
**核心能力一页速览**(`★` 为 v1.7.5–1.8.
|
|
116
|
+
**核心能力一页速览**(`★` 为 v1.7.5–1.8.1 新栈):
|
|
117
117
|
|
|
118
118
|
- **看板**:六列状态流、拖拽 / 多选批量 / 文本·优先级·标签筛选、归档留档、会话隔离
|
|
119
|
-
- **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管、**spawn 落闸锁内重验**(spawnGateOk:claim 占位与落闸决策读同一把锁的同一份快照,terminate 落在 spawn
|
|
119
|
+
- **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管、**spawn 落闸锁内重验**(spawnGateOk:claim 占位与落闸决策读同一把锁的同一份快照,terminate/dependsOn 后补 落在 spawn 在途窗口即弃权——竞态与后补依赖双双根治)、**高优干预分态**(intervene 无活跃 run 时区分「续跑窗口,干预随续跑注入」与「真无 run」,返回体带 state 字段)、**建卡派发预警**(将自动派发时 lintWarnings 瞬态提示「约 1s 内被领取,想占位请 draft:true」)
|
|
120
120
|
- **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+迟到驳回直投告警,不再静默吞掉)
|
|
121
121
|
- **度量** `★`:Token 消耗(总量 / 有效 / 缓存读,按模型·按任务·按日)+ 记分卡(模型表现 / 质量趋势 / 驳回聚类)
|
|
122
|
-
- **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)、**僵尸 run 留档清扫**(host 重启后「落定卡上的 running 无 endedAt 留档」在派发周期自动关账 `stale-closed` + 清认领位,归档门禁不再被幻影 run
|
|
122
|
+
- **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)、**僵尸 run 留档清扫**(host 重启后「落定卡上的 running 无 endedAt 留档」在派发周期自动关账 `stale-closed` + 清认领位,归档门禁不再被幻影 run 永久拦截)、**子会话生命周期收口**(一次性 run 落定/迟到 verdict 落账后主动 interrupt 子会话——「结算≠停止」空转根治;continuable 收紧为「续跑位真在本 child 手上」才豁免,残影 Worker 无处藏身)、**terminate 三保险**(no-rec 也按认领位盲杀补刀 + interrupt 失败 err 级可见化 + `hold:true` 原子「终止+转手动」)、**配置审计**(set-board-config 成功写入落板级 configLog {key,from,to,actor,at} 截尾 50 条;模型下拉两段式选中≠保存)
|
|
123
123
|
- **学习反馈**:候选教训信号 → 主窗口沉淀(零耦合,不碰任何笔记 / 记忆工具 API)
|
|
124
124
|
|
|
125
125
|
以下各节是完整规格。
|
|
@@ -189,6 +189,7 @@ draft → pending → in-progress → verifying → resolved → archived
|
|
|
189
189
|
- **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
|
|
190
190
|
- **git 纪律红线**:Worker prompt 写死禁止还原命令(`git checkout` / `git restore` / `git reset --hard` / `git clean` 等——会冲掉并行 Worker 与你自己的未提交在途编辑);只改自己写的文件、别人改了别的文件与你无关,确实需要干净基线时走歧义上报(`board_report` kind=escalate)由主窗口裁决;**提交只允许精确路径 `git add`**(本卡 `touches` 声明的文件,或逐一列出明确改过的文件路径),禁止 `git add -A` / `git add .` / `git add 目录`——并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进 commit(实证 n-mv6xntg1p6i5:三起暂存吞并)
|
|
191
191
|
- **大文件交付纪律**:Worker prompt 写死分块写入指引——单文件交付预估 >8KB(或 spec 含全量文案/长代码)时禁止单次 `write` 写入全量(单次超长输出会撞模型输出上限、上下文耗尽零产出),先 `write` 落主体骨架、再用 `edit` 追加续块(每块 ≤4KB)逐块落地
|
|
192
|
+
- **前轮半成品提示**:卡片 runs[] 含已落定 worker run 时,Worker prompt 追加「先 `git status` 看工作区有无未提交半成品,能续作就续作,别盲目从零」——重发/重派场景的旧产物先盘点再决定
|
|
192
193
|
- **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁随工作态**:状态流转到已完成(`resolved`)即放锁,`cancelled`/归档同样不再持锁(归档回归纯收纳动作、不再是释放点)——锁只护「正在写」的阶段,「验收后-提交前」的窗口期由主窗口「回执到即提交」纪律 + 史诗 post-hook 承接,不用长持锁把整批串行化。避免并行 Worker 改同一批文件互踩;手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
|
|
193
194
|
- **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
|
|
194
195
|
- **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
|
package/lib/client/dashboard.js
CHANGED
|
@@ -950,12 +950,16 @@
|
|
|
950
950
|
}, [])
|
|
951
951
|
useEffect(function () { setVal(props.value || ''); setDirty(false) }, [props.value])
|
|
952
952
|
function save() { setDirty(false); rpc('set-board-config', { key: cfgKey, value: (val || '').trim() }).then(fetchTasks).catch(function () {}) }
|
|
953
|
-
|
|
953
|
+
// 下拉两段式(task-mv9yuq2l,实证 n-mv8ki92qnhdq:巡检戳即点即写 → verifierModel 神隐回退无从审计):
|
|
954
|
+
// 选中只改本地草稿态 + 亮 dirty(对比服务端权威值 props.value),点旁边 ✓ 保存键才走 save() 提交 rpc——
|
|
955
|
+
// 与下方降级文本输入同款两段式语义,巡检路过戳一下不再直接写配置。
|
|
956
|
+
function pick(v) { setVal(v); setDirty(v !== (props.value || '')) }
|
|
954
957
|
if (models) {
|
|
955
958
|
return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } }, label,
|
|
956
|
-
React.createElement('select', { value: val, onChange: function (e) { pick(e.target.value) }, title: label + '
|
|
959
|
+
React.createElement('select', { value: val, onChange: function (e) { pick(e.target.value) }, title: label + '(空=继承父级;选中后点 ✓ 才保存)', style: { width: 150, padding: '0px 4px', fontSize: 9, border: '1px solid ' + (dirty ? C.brand : C.border), borderRadius: 2, background: C.card, color: C.text } },
|
|
957
960
|
React.createElement('option', { value: '' }, '继承父级'),
|
|
958
|
-
models.map(function (m) { return React.createElement('option', { key: m.id, value: m.id }, m.name + ' (' + m.provider + ')') }))
|
|
961
|
+
models.map(function (m) { return React.createElement('option', { key: m.id, value: m.id }, m.name + ' (' + m.provider + ')') })),
|
|
962
|
+
dirty ? React.createElement('button', { onClick: save, title: '保存 ' + label, style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
|
|
959
963
|
}
|
|
960
964
|
// 降级:文本输入(模型枚举不可用时)
|
|
961
965
|
return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } }, label,
|
package/lib/client.js
CHANGED
|
@@ -2487,12 +2487,16 @@ function apply(ctx) {
|
|
|
2487
2487
|
}, [])
|
|
2488
2488
|
useEffect(function () { setVal(props.value || ''); setDirty(false) }, [props.value])
|
|
2489
2489
|
function save() { setDirty(false); rpc('set-board-config', { key: cfgKey, value: (val || '').trim() }).then(fetchTasks).catch(function () {}) }
|
|
2490
|
-
|
|
2490
|
+
// 下拉两段式(task-mv9yuq2l,实证 n-mv8ki92qnhdq:巡检戳即点即写 → verifierModel 神隐回退无从审计):
|
|
2491
|
+
// 选中只改本地草稿态 + 亮 dirty(对比服务端权威值 props.value),点旁边 ✓ 保存键才走 save() 提交 rpc——
|
|
2492
|
+
// 与下方降级文本输入同款两段式语义,巡检路过戳一下不再直接写配置。
|
|
2493
|
+
function pick(v) { setVal(v); setDirty(v !== (props.value || '')) }
|
|
2491
2494
|
if (models) {
|
|
2492
2495
|
return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } }, label,
|
|
2493
|
-
React.createElement('select', { value: val, onChange: function (e) { pick(e.target.value) }, title: label + '
|
|
2496
|
+
React.createElement('select', { value: val, onChange: function (e) { pick(e.target.value) }, title: label + '(空=继承父级;选中后点 ✓ 才保存)', style: { width: 150, padding: '0px 4px', fontSize: 9, border: '1px solid ' + (dirty ? C.brand : C.border), borderRadius: 2, background: C.card, color: C.text } },
|
|
2494
2497
|
React.createElement('option', { value: '' }, '继承父级'),
|
|
2495
|
-
models.map(function (m) { return React.createElement('option', { key: m.id, value: m.id }, m.name + ' (' + m.provider + ')') }))
|
|
2498
|
+
models.map(function (m) { return React.createElement('option', { key: m.id, value: m.id }, m.name + ' (' + m.provider + ')') })),
|
|
2499
|
+
dirty ? React.createElement('button', { onClick: save, title: '保存 ' + label, style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
|
|
2496
2500
|
}
|
|
2497
2501
|
// 降级:文本输入(模型枚举不可用时)
|
|
2498
2502
|
return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } }, label,
|
package/lib/core.mjs
CHANGED
|
@@ -160,7 +160,7 @@ export function boardHome() { return path.join(boardDirName(), '.dsh') }
|
|
|
160
160
|
// ownerCwd(跨重启继承):创建该看板的会话工作区路径,继承判定全靠它——取不到就省略字段
|
|
161
161
|
// (绝不落空串,否则「路径读不到的多个会话」会被误判成同一工作区)。
|
|
162
162
|
export function seed(sid, ownerCwd) {
|
|
163
|
-
var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, cleanupRetentionDays: 7, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
|
|
163
|
+
var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, cleanupRetentionDays: 7, poolStatus: { workers: [], verifiers: [] }, tasks: [], configLog: [] }
|
|
164
164
|
if (typeof ownerCwd === 'string' && ownerCwd) d.ownerCwd = ownerCwd
|
|
165
165
|
return d
|
|
166
166
|
}
|
|
@@ -182,6 +182,9 @@ export function normalizeBoard(d) {
|
|
|
182
182
|
if (typeof d.epicSplit !== 'boolean') d.epicSplit = true
|
|
183
183
|
if (typeof d.workerContinuable !== 'boolean') d.workerContinuable = true
|
|
184
184
|
if (typeof d.verifyUserGuide !== 'boolean') d.verifyUserGuide = true
|
|
185
|
+
// 配置审计行(实证 n-mv8ki92qnhdq:verifierModel 神隐回退零留痕无从审计):老看板缺字段/脏值 → 补空数组;
|
|
186
|
+
// 写入由 set-board-config 成功时追加 {key, from, to, actor, at}(截尾 50 条),此处只兜底形态。
|
|
187
|
+
if (!Array.isArray(d.configLog)) d.configLog = []
|
|
185
188
|
// 清理保留天数(卡龄主闸):缺字段/脏值(非数或 <1)→ 7;合法值四舍五入并钳 1-90。
|
|
186
189
|
if (typeof d.cleanupRetentionDays !== 'number' || !(d.cleanupRetentionDays >= 1)) d.cleanupRetentionDays = 7
|
|
187
190
|
else d.cleanupRetentionDays = Math.max(1, Math.min(90, Math.round(d.cleanupRetentionDays)))
|
|
@@ -753,6 +756,15 @@ export function buildFileOutline(content) {
|
|
|
753
756
|
// 已经建好的单张卡(要拆也轮不到它拆,真觉得大应走歧义上报)。故 epicSplit=false 时 Worker prompt 字面
|
|
754
757
|
// 与 true 时逐字相同(单测锁定 parity,防止将来有人往这里塞拆分条款而漏接门禁);buildWorkerPrompt
|
|
755
758
|
// 因此不引入 epicSplit 形参——没有条款可跳过,加个无用参数只会是死代码。
|
|
759
|
+
// 前轮半成品提示的命中判定(task-mvbeallq,残影治理②):卡 runs[] 里有**已落定**的 worker run
|
|
760
|
+
// (outcome 已写且非 running——running 是僵尸/在跑留档,不构成「有半成品可续作」的依据)。
|
|
761
|
+
// 「非本条 run」天然成立:本条 run 的留档在 spawn 成功后才由 recordRunHistory 追加,buildWorkerPrompt
|
|
762
|
+
// 跑在 spawn 之前,此刻 runs[] 里全是前轮;续跑路径不走本 prompt(tryResumeWorker 自带断点续跑指令)。
|
|
763
|
+
function hasSettledWorkerRun(t) {
|
|
764
|
+
if (!t || !Array.isArray(t.runs)) return false
|
|
765
|
+
for (var i = 0; i < t.runs.length; i++) { var r = t.runs[i]; if (r && r.role === 'worker' && r.outcome && r.outcome !== 'running') return true }
|
|
766
|
+
return false
|
|
767
|
+
}
|
|
756
768
|
export function buildWorkerPrompt(t, pack, feedbackEnabled) {
|
|
757
769
|
var notes = histNotes(t)
|
|
758
770
|
var msgs = buildMessages(t)
|
|
@@ -761,6 +773,9 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
|
|
|
761
773
|
if (t.acceptance) p += '\n硬性验收脚本: ' + t.acceptance + '\n(必须实际运行该命令并在自测情况中粘贴真实输出;未通过不得上报完成)'
|
|
762
774
|
if (notes) p += '\n\n该任务的过程记录(歧义上报/主窗口裁决/驳回/干预,请务必遵循最新裁决方向):\n' + notes
|
|
763
775
|
if (msgs) p += '\n\n该任务的详细消息(裁决答案/干预指令/歧义原文等,请务必遵循):\n' + msgs
|
|
776
|
+
// 前轮半成品提示(task-mvbeallq):有前轮已落定 worker run → 工作区可能躺着前轮未提交的半成品
|
|
777
|
+
// (残影被补停/池故障重派/驳回重做等场景),先 git status 盘点、能续作就续作,别盲目从零。
|
|
778
|
+
if (hasSettledWorkerRun(t)) p += '\n\n本卡有前轮 run 的痕迹——先 git status 看工作区有无未提交的半成品改动,能续作就在其基础上续作,别盲目从零推倒重来。'
|
|
764
779
|
if (pack) p += '\n\n' + pack
|
|
765
780
|
// 学习飞轮 v1 软召回(feedbackEnabled 关闭时不出现):只提示"先查历史教训",看板绝不代查——
|
|
766
781
|
// 环境里有没有笔记/记忆类工具、教训库长什么样,都是 Worker 自己判断的事(零耦合)。
|
|
@@ -1060,6 +1075,35 @@ export function draftLint(t, io) {
|
|
|
1060
1075
|
return out
|
|
1061
1076
|
}
|
|
1062
1077
|
|
|
1078
|
+
// ===== 建卡派发预警(task-mvbealtf / 巡检反馈 n-mvba2neatqoa)=====
|
|
1079
|
+
// 巡检实证:agent 用 task_create 建「纯占位/测试」卡(非 Team 缺省 draft=false)0.145s 即被
|
|
1080
|
+
// 派发、烧 219K token 才终止——建卡响应只有验收/调研类 warning,无任何「将立即派发」提示。
|
|
1081
|
+
// 此预警只挂建卡返回体(瞬态提示,不落 task.lintWarnings 持久字段:落盘会在详情页长期残留
|
|
1082
|
+
// 过期文案,且 update-task 走 draftLint 重算会把它冲掉、两种口径自相矛盾)。
|
|
1083
|
+
// 判定只覆盖「建卡即入池」形态(pending + auto + 非 direct + 依赖满足);touches 锁/池暂停
|
|
1084
|
+
// 只可能延迟领取、不取消入池事实,故文案说「将进入派发池」而非「已被领取」。
|
|
1085
|
+
export var DISPATCH_NOW_LINT = '此卡将立即进入派发池(约 1s 内被 Worker 领取);想先占位请 draft:true 建草稿'
|
|
1086
|
+
|
|
1087
|
+
// 将自动派发形态判定:pending(非 draft)且 assignMode=auto 且 pipeline≠direct 且依赖已满足。
|
|
1088
|
+
// 纯函数零 IO(复用同文件 depsSatisfied);t 为刚建好的任务对象。
|
|
1089
|
+
export function willAutoDispatch(d, t) {
|
|
1090
|
+
if (!d || !t || typeof t !== 'object') return false
|
|
1091
|
+
if (t.status !== 'pending') return false
|
|
1092
|
+
if ((t.assignMode || 'auto') !== 'auto') return false
|
|
1093
|
+
if ((t.pipeline || 'full') === 'direct') return false
|
|
1094
|
+
return depsSatisfied(d, t)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// 建卡返回体追加派发预警(task_create 工具 / create-task RPC 双通道同口径):
|
|
1098
|
+
// 命中自动派发形态时,把响应里的 task 换成「lintWarnings 追加 DISPATCH_NOW_LINT」的浅拷贝——
|
|
1099
|
+
// 持久 task 本体不动(响应瞬态提示,见上方注释);未命中/异常入参原样返回(返回体形态不变)。
|
|
1100
|
+
export function appendDispatchWarning(out, d, t) {
|
|
1101
|
+
if (!out || !out.ok || !out.task || !willAutoDispatch(d, t)) return out
|
|
1102
|
+
var base = Array.isArray(t.lintWarnings) ? t.lintWarnings : []
|
|
1103
|
+
out.task = Object.assign({}, t, { lintWarnings: base.concat([DISPATCH_NOW_LINT]) })
|
|
1104
|
+
return out
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1063
1107
|
// ===== touches → suggestedContextFiles 自动桥接(调研遵循·host 三件套 ①)=====
|
|
1064
1108
|
// 让遵守成为最省力路径:建卡触发「无调研上下文」warning 时,直接把 touches 里的具体文件
|
|
1065
1109
|
// 提炼成可一键采纳的 contextFiles 建议(响应挂 suggestedContextFiles 字段)。
|
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):派发侧不再读盘——
|
|
@@ -182,6 +182,10 @@ export function createDispatch(ctx, state, deps) {
|
|
|
182
182
|
// 一把锁、同一份文档),不能只信锁外快照:
|
|
183
183
|
// · worker 一律验:卡仍 in-progress 且认领位仍是本轮占位 'spawn-pending'(terminate 会清占位并
|
|
184
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 门控只闸「挑候选」一刻,闸不到在途窗口);
|
|
185
189
|
// · 续跑追加验:锁内重算的 resumeTarget 仍指向同一个 childId(terminate 命中活跃 rec 时会把
|
|
186
190
|
// 留档关成 'terminated',续跑资格随之失效——锁内重算天然覆盖这条)。
|
|
187
191
|
// 用法契约:spawn 返回后**零 await 先登记 rec**(保住事件可见性既有纪律),再过本闸门;
|
|
@@ -195,13 +199,34 @@ export function createDispatch(ctx, state, deps) {
|
|
|
195
199
|
try {
|
|
196
200
|
await mutateLocked(sid, function (d) {
|
|
197
201
|
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))
|
|
202
|
+
ok = !!(t && t.status === 'in-progress' && t.claimedBy === 'spawn-pending' && depsSatisfied(d, t) && (!resumeId || resumeTarget(t) === resumeId))
|
|
199
203
|
return null
|
|
200
204
|
}, true)
|
|
201
205
|
} catch (e) { console.error('[task-board] spawn 闸门重验异常(按弃权处理,宁停勿双)task ' + taskId + ':', String(e)); ok = false }
|
|
202
206
|
return ok
|
|
203
207
|
}
|
|
204
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
|
+
|
|
205
230
|
async function spawnOneShot(sid, t, role) {
|
|
206
231
|
var subagents = ctx.subagents; if (!subagents) return null
|
|
207
232
|
// 兜底保留(真异常时有用):poolCycle 入口已有 root 存活早闸门,正常派发路径不会到达这里;
|
|
@@ -313,6 +338,9 @@ export function createDispatch(ctx, state, deps) {
|
|
|
313
338
|
if (useContinuable) { if (typeof subagents.interrupt === 'function') subagents.interrupt(childId, { kind: 'ancestor', agent: parent }) }
|
|
314
339
|
else if (run && typeof run.dispose === 'function') await run.dispose()
|
|
315
340
|
} catch (_) {}
|
|
341
|
+
// 依赖否决收口(task-mv9yupm4):dependsOn 后补导致的否决无人翻卡(update-task 不动状态机),
|
|
342
|
+
// 条件化把仍停在本轮占位的卡翻回 pending;terminate 竞态的翻卡已由 terminate 完成,此调用零改动。
|
|
343
|
+
await revertDepsVeto(sid, t.id)
|
|
316
344
|
console.error('[task-board] spawn 落闸被锁内重验拒绝(卡面已变更,本次派发弃权)task ' + t.id + ' role ' + role)
|
|
317
345
|
return { vetoed: true }
|
|
318
346
|
}
|
|
@@ -482,13 +510,16 @@ export function createDispatch(ctx, state, deps) {
|
|
|
482
510
|
if (r2.noResume === true) return null
|
|
483
511
|
if (r2.outcome !== 'timeout/error' && r2.outcome !== 'incomplete') return null // 仍 running 等形态归结算/终止路径管
|
|
484
512
|
r2.noResume = true
|
|
485
|
-
ah(t2, t2.status, t2.status, 'system', '派发落闸重验未通过(terminate
|
|
513
|
+
ah(t2, t2.status, t2.status, 'system', '派发落闸重验未通过(terminate 竞态/依赖后补):续跑弃权,旧子会话不再续跑')
|
|
486
514
|
return { ok: true }
|
|
487
515
|
}
|
|
488
516
|
return null
|
|
489
517
|
}, true)
|
|
490
518
|
} catch (_) {}
|
|
491
|
-
|
|
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)
|
|
492
523
|
return { vetoed: true }
|
|
493
524
|
}
|
|
494
525
|
armTimeouts(sid, rec, t, c)
|
|
@@ -505,6 +536,57 @@ export function createDispatch(ctx, state, deps) {
|
|
|
505
536
|
// 无论从哪条入口进来(事件通道 settleContinuable / 上报通道 board_report),run 记录的收尾动作
|
|
506
537
|
// 必须一致:①关 run 结局(closeRunHistory)②usage 落账(seq 水位线增量)③摘除软/硬超时臂。
|
|
507
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
|
+
|
|
508
590
|
async function settleRunRecord(sid, rec, opts) {
|
|
509
591
|
// ① 结局落卡:failed → 有 err 记 timeout/error,无 err 记 incomplete(与一次性路径口径一致);
|
|
510
592
|
// 成功 → completed。noResume 旗随 rec.resumeBlocked(interrupt 失败)落卡。
|
|
@@ -520,6 +602,11 @@ export function createDispatch(ctx, state, deps) {
|
|
|
520
602
|
// ② settle 通道心跳(运行时健康自检):三件套各自幂等且各自 catch 永不抛,
|
|
521
603
|
// 走到这里即「一次成功结算活动」——rpc 侧据此判定「结算通道 >30min 无活动」黄条。
|
|
522
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)) }
|
|
523
610
|
}
|
|
524
611
|
|
|
525
612
|
async function settleRun(sid, rec, res, err) {
|
|
@@ -787,8 +874,9 @@ export function createDispatch(ctx, state, deps) {
|
|
|
787
874
|
// · 只认 continuable 且 role==='worker' 的活跃 rec:verifier/hook 是一次性 run。它们的 run.result
|
|
788
875
|
// 落定后 settleRun 会照常收尾;手动终止路径**刻意**不关 run(保留 running = 不留续跑资格),此处不碰。
|
|
789
876
|
// · 摘除活跃表项:任务已落定,这张卡不该再占 Worker 并发位;否则 poolCycle 的 activeW 会把它算成
|
|
790
|
-
// 在跑的 Worker
|
|
791
|
-
//
|
|
877
|
+
// 在跑的 Worker(幽灵占位)。子会话由 settleRunRecord 末尾的 stopSettledOneShot 补停
|
|
878
|
+
// (task-mvbeallq:completed 落定天然无续跑位——resumeTarget 只认超时/失败结局——「续跑豁免」
|
|
879
|
+
// 留下的就是残影续跑窗;补停只打断 turn 不销毁会话,与 endContinuable 同款语义)。
|
|
792
880
|
// · 幂等:closeRunHistory 只关 outcome==='running' 的条目、accumulateRunUsage 认 usageRecorded 旗、
|
|
793
881
|
// rec.disarm 幂等——重复上报 / 上报后 idle 事件补到,都不会二次关账或二次记账。
|
|
794
882
|
// 老宿主/没接线的调用方:deps 里没有这个函数时 rpc 侧静默跳过,行为与本次改造前逐字一致。
|
|
@@ -1038,7 +1126,26 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1038
1126
|
if (String(last.id) !== String(t.claimedBy)) continue // 卡上留档与占位不一致:状态可疑,不动
|
|
1039
1127
|
cands.push(t)
|
|
1040
1128
|
}
|
|
1041
|
-
|
|
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
|
|
1042
1149
|
// 存活判定:listChildren 列出的就是「活的子会话」。带超时保护(查询挂死也不能拖住心跳)。
|
|
1043
1150
|
var kids = null
|
|
1044
1151
|
try {
|
|
@@ -1098,6 +1205,22 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1098
1205
|
}, true)
|
|
1099
1206
|
} catch (e) { console.error('[task-board] reconcile 死会话回收失败:', String(e)) }
|
|
1100
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 + ' 个')
|
|
1101
1224
|
}
|
|
1102
1225
|
|
|
1103
1226
|
// ===== 僵尸 run 留档清扫(task-mv22z61v):关账「落定卡上的 running 无 endedAt 留档」+ 清卡面认领位 =====
|
|
@@ -1501,6 +1624,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1501
1624
|
// 落闸弃权(task-mv6c7cdm):卡面已被 terminate 等路径重排,本轮 claim 作废——
|
|
1502
1625
|
// 占位由翻卡那笔写清掉、被弃的子会话已由闸门 interrupt/dispose 停掉,这里刻意不再动卡面、
|
|
1503
1626
|
// 不发「启动失败」误报(闸门函数内已各留一行 console 痕迹),等下一轮 cycle 重新决策。
|
|
1627
|
+
// dependsOn 后补导致的否决(task-mv9yupm4):占位由闸门调用方的 revertDepsVeto 条件化
|
|
1628
|
+
// 翻回 pending(依赖未满足 → pickDispatch 门控不派;依赖落定那笔写 kick 后自动重派)。
|
|
1504
1629
|
} else if (sp.role === 'worker') {
|
|
1505
1630
|
// spawn 失败 → 回 pending
|
|
1506
1631
|
await releaseSpawnPlaceholder(sid, sp, 'spawn 失败,回收重新排队')
|
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 宽限后亮条,
|