dsh-agent-board 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -13
- package/index.mjs +13 -5
- package/lib/client/dashboard.js +120 -39
- package/lib/client/kernel.js +44 -6
- package/lib/client/task-detail.js +7 -3
- package/lib/client.js +171 -48
- package/lib/core.mjs +61 -29
- package/lib/dispatch.mjs +920 -226
- package/lib/rpc.mjs +127 -18
- package/lib/store.mjs +26 -1
- package/lib/usage.mjs +323 -20
- package/package.json +1 -1
- package/test/core.test.mjs +2066 -107
- package/test/usage-mainwindow.test.mjs +230 -0
package/lib/dispatch.mjs
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
// dsh-agent-board — 派发引擎(lib/dispatch.mjs)
|
|
2
2
|
// createDispatch(ctx, state, deps):一次性子代理 spawn/结算(软+硬两级超时)/ run 历史留档 /
|
|
3
3
|
// token 消耗累加 / poolCycle 派发周期(孤儿回收+占位 claim+池快照)/ 15s 心跳 / 插件卸载清理 /
|
|
4
|
-
// Team 模式 systemPrompt 引导段 /
|
|
4
|
+
// Team 模式 systemPrompt 引导段 / 预研上下文瘦身清单(调研笔记全文 + 文件清单,随首条 prompt 一次性注入)/
|
|
5
|
+
// 可续跑 Worker(continuable:硬超时 interrupt 留存 + 重派 sendMessage 冷复活续跑 + 失败回退 fresh spawn)。
|
|
6
|
+
import { statSync } from 'node:fs'
|
|
5
7
|
import * as core from './core.mjs'
|
|
6
|
-
import { readRunUsage } from './usage.mjs'
|
|
8
|
+
import { readRunUsage, findRunLog, readLogBytes, readLogFrames } from './usage.mjs'
|
|
7
9
|
import { splitRuleOf, pushRejectLesson } from './policy.mjs'
|
|
8
|
-
const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection,
|
|
10
|
+
const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection } = core
|
|
9
11
|
|
|
10
12
|
export function createDispatch(ctx, state, deps) {
|
|
11
|
-
|
|
13
|
+
// 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
|
|
14
|
+
// 清单只给「路径:L行号 — 一句用途」,文件内容由 Worker 自己用 read 工具按行号范围自取。
|
|
12
15
|
var rt = deps.rt, wt = deps.wt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
|
|
13
|
-
var rootForSession = deps.rootForSession,
|
|
16
|
+
var rootForSession = deps.rootForSession, withTimeout = deps.withTimeout, runsFor = deps.runsFor, feedbackOn = deps.feedbackOn
|
|
17
|
+
// 会话日志根(可选注入):usage 增量结算(卡3)必须「真读真日志」才测得出重复计账——
|
|
18
|
+
// 生产不注入(usage.mjs 回退 os.homedir()),单测注入临时目录(否则只能开子进程改 HOME,
|
|
19
|
+
// 而同进程改 os.homedir 会污染整个测试进程)。老宿主/老测试桩无此依赖 → undefined,行为不变。
|
|
20
|
+
var sessionsRoot = (typeof deps.sessionsRoot === 'string' && deps.sessionsRoot) ? deps.sessionsRoot : undefined
|
|
14
21
|
// 史诗拆分总开关读取器(epicSplit,缺省 true):与 feedbackOn 同源(session 缓存,rt() 同步)——
|
|
15
22
|
// Team 提示词组装是同步函数,只能读缓存,不能读盘。
|
|
16
23
|
var epicSplitOn = deps.epicSplitOn
|
|
@@ -20,8 +27,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
20
27
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
21
28
|
var dispatchedEver = state.dispatchedEver
|
|
22
29
|
var badModels = state.badModels
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
// 「上下文注入」区块通道的按子代理会话缓存表 + 首轮竞速认领队列随该通道一起退役(task-muvjs392):
|
|
31
|
+
// 瘦身清单直接进首条 prompt,不再需要注入段命中与父子归属认领(并行 spawn 认领错包的潜伏 bug 一并消灭)。
|
|
25
32
|
var knownSessions = state.knownSessions
|
|
26
33
|
var teamModeCache = state.teamModeCache
|
|
27
34
|
|
|
@@ -46,103 +53,115 @@ export function createDispatch(ctx, state, deps) {
|
|
|
46
53
|
_cachedProvider = names[0]; return _cachedProvider
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
var paths = (t.context && Array.isArray(t.context.files)) ? t.context.files : []
|
|
58
|
-
var notes = (t.context && typeof t.context.notes === 'string') ? t.context.notes : ''
|
|
56
|
+
// 主窗口预研上下文(瘦身分离形态,task-muvjs392):只组装**瘦身清单**——调研笔记全文(≤8000 字符)
|
|
57
|
+
// + 文件清单(每行「路径:L行号 — 一句用途」,用途取自 contextFiles 条目的可选注释位)。
|
|
58
|
+
// 文件内容本体不进 prompt、host 也不再读盘(IO 清零):由 Worker 用 read 工具按行号范围自取——执行时
|
|
59
|
+
// 盘面更新鲜,且免了大文件正文(旧形态单文件 8KB/总包 40KB 截断注入)既撑 prompt 又随快照每轮重发
|
|
60
|
+
// (实证 6×48.8K 字符≈白烧 75–100K token;整包塞 prompt 同样有害,故取「分离 + 按需自取」)。
|
|
61
|
+
function readContextPack(t) {
|
|
62
|
+
var paths = (t && t.context && Array.isArray(t.context.files)) ? t.context.files : []
|
|
63
|
+
var notes = (t && t.context && typeof t.context.notes === 'string') ? t.context.notes : ''
|
|
59
64
|
if (!paths.length && !notes.trim()) return ''
|
|
60
|
-
var
|
|
61
|
-
var
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// 盘符冒号不会被误判(parseAnchorPath 只认尾部 :L<num>);展示路径保留原始写法。
|
|
67
|
-
var anchor = parseAnchorPath(p)
|
|
68
|
-
try {
|
|
69
|
-
var full = await fs.readText(await fs.resolve(anchor.file, cwd ? { cwd: cwd } : undefined))
|
|
70
|
-
var content = full, truncated = false, meta = '', outline = null
|
|
71
|
-
var anchorFrom = null // 锚点段起点(预算二次截断时换算注入末行用)
|
|
72
|
-
if (anchor.from != null) {
|
|
73
|
-
var seg = sliceLines(full, anchor.from, anchor.to)
|
|
74
|
-
if (seg.invalid) {
|
|
75
|
-
// 段超范围 → 回退头部注入并标注(调用方写明锚点意图,Worker 可据此换锚点重读)
|
|
76
|
-
meta = '锚点 L' + anchor.from + (anchor.to != null ? '-L' + anchor.to : '') + ' 无效(共 ' + seg.totalLines + ' 行),已回退头部'
|
|
77
|
-
} else {
|
|
78
|
-
content = seg.text
|
|
79
|
-
anchorFrom = seg.injectedFrom
|
|
80
|
-
meta = '锚点行段:共 ' + seg.totalLines + ' 行,已注入 L' + seg.injectedFrom + '–L' + seg.injectedTo + (seg.capped ? '(超 400 行段长上限)' : '')
|
|
81
|
-
}
|
|
82
|
-
} else if (anchor.invalidAnchor) {
|
|
83
|
-
meta = '锚点写法无效,已回退头部'
|
|
84
|
-
}
|
|
85
|
-
// 预算口径不变:单文件 8KB、总计 40KB(锚点段同样计入)
|
|
86
|
-
if (content.length > 8192) { content = content.slice(0, 8192); truncated = true }
|
|
87
|
-
if (total + content.length > 40960) { content = content.slice(0, 40960 - total); truncated = true }
|
|
88
|
-
if (truncated) {
|
|
89
|
-
// 截断标注升级(①):从「(截断)」升级为「共 N 行,已注入 X–M 行」
|
|
90
|
-
var gotLines = content ? content.split('\n').length : 0
|
|
91
|
-
if (anchorFrom != null) {
|
|
92
|
-
meta += ';预算截断到 L' + (anchorFrom + gotLines - 1)
|
|
93
|
-
} else {
|
|
94
|
-
var totalLines = full.split('\n').length
|
|
95
|
-
meta += (meta ? ';' : '') + '截断:共 ' + totalLines + ' 行,已注入 1–' + gotLines + ' 行'
|
|
96
|
-
// 结构索引(①④):头部注入被截断时附上,Worker 可照索引用锚点语法直读目标段
|
|
97
|
-
outline = buildFileOutline(full)
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
var entry = { path: p, content: content, truncated: truncated }
|
|
101
|
-
if (meta) entry.meta = meta
|
|
102
|
-
if (outline && outline.length) entry.outline = outline
|
|
103
|
-
out.push(entry)
|
|
104
|
-
total += content.length
|
|
105
|
-
} catch (e) {
|
|
106
|
-
out.push({ path: p, content: '[读取失败: ' + String(e).slice(0, 120) + ']', truncated: false })
|
|
107
|
-
}
|
|
65
|
+
var files = []
|
|
66
|
+
for (var i = 0; i < paths.length; i++) {
|
|
67
|
+
// 锚点行段语法 'path:L2350-L2420' / 'path:L2350' 与可选「 — 一句用途」由 core 纯函数归一,
|
|
68
|
+
// 清单里原样带上行号供 Worker 直接 read(file, offset, limit) 自取。
|
|
69
|
+
var e = parseContextFileEntry(paths[i])
|
|
70
|
+
if (e.path) files.push(e)
|
|
108
71
|
}
|
|
109
|
-
return buildContextPackSection(
|
|
72
|
+
return buildContextPackSection(files, notes.slice(0, 8000))
|
|
110
73
|
}
|
|
111
74
|
|
|
112
|
-
//
|
|
113
|
-
// 区块呈现(与 skill-catalog 等系统注入同形态)。
|
|
114
|
-
// 首轮竞速:子代理的首次 prompt 组装发生在 subagents.start() 返回之前,packByChild 还没写入
|
|
115
|
-
// → spawn 前把 pack 放进 pendingPacks,provider 按父子归属(isOwnedBy 父 agent)即时认领。
|
|
116
|
-
// packByChild / pendingPacks 容器本体在 state(见上方别名)
|
|
117
|
-
|
|
118
|
-
// 历史会话留档:t.runs = [{ role, id, at, model, outcome, endedAt }],上限 20 条
|
|
75
|
+
// 历史会话留档:t.runs = [{ role, id, at, model, outcome, endedAt, continuable?, resume?, noResume? }],上限 20 条
|
|
119
76
|
// 目的:任务流转到 resolved/archived 后,详情页仍能选择跳转到任一历史阶段的会话
|
|
120
77
|
// (Worker 首次/重试、Verifier 各次),而不是只剩最后一次 run id。
|
|
121
|
-
|
|
78
|
+
// 卡2 追加三个标记位(都是**续跑判定的唯一依据**,不再另建内存表——重启后从卡上原样恢复):
|
|
79
|
+
// continuable:该 run 是持久子会话,超时/失败后可被 sendMessage 冷复活(一次性 run 无此标记 → 不可续跑);
|
|
80
|
+
// resume:本条本身就是一次续跑记录(详情页可区分「首派 / 续跑」);
|
|
81
|
+
// noResume:该子会话不可信(interrupt 失败/无 root 可授权)→ 重派直接 fresh spawn,不做空唤醒。
|
|
82
|
+
// usage / usageSeq / usageRecorded:usage 落账(卡3 seq 水位线增量)与「已落账」幂等旗
|
|
83
|
+
// (task-muwkhqf8:continuable 的 run 记录现在有两条结算入口——事件通道(agent/status)与
|
|
84
|
+
// 上报通道(board_report 工具完成即收尾),重复关账必须不二次记账)。
|
|
85
|
+
// 两函数都对「同一条 run 记录」幂等:closeRunHistory 只认 outcome==='running' 的条目——重复 settle
|
|
86
|
+
// (idle 事件重入/上报与事件双通道)不覆盖结局、不刷新 endedAt(否则重入会把「已结算」伪装成本次
|
|
87
|
+
// 结算的产物)。recordRunHistory 的幂等**只在续跑轮(resume)启用**:续跑轮与首轮共用同一个子会话 id,
|
|
88
|
+
// 若 sendMessage 的登记被重试一次,就会出现两条同 id 且都停在 running 的记录(续跑资格与 usage 水位线
|
|
89
|
+
// 双双错乱)——故「同 id 已有未落定的条目」时不再追加;而**首派/续跑回退 fresh spawn 一律追加新条目**
|
|
90
|
+
// (同一 childId 的多次派发各占一条,这是 usage 水位线分段的前提,见 seekSeqOf)。
|
|
91
|
+
async function recordRunHistory(sid, taskId, role, runId, model, hardMin, continuable, resume) {
|
|
122
92
|
try {
|
|
123
93
|
await mutateLocked(sid, function (d) {
|
|
124
94
|
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
125
95
|
if (!t) return null // 找不到任务:返回 null 不写盘
|
|
126
96
|
if (!Array.isArray(t.runs)) t.runs = []
|
|
127
|
-
|
|
97
|
+
if (resume) {
|
|
98
|
+
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
99
|
+
var r0 = t.runs[ri]
|
|
100
|
+
if (r0 && String(r0.id) === String(runId) && (!r0.outcome || r0.outcome === 'running')) return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
var e = { role: role, id: runId, at: new Date().toISOString(), model: model || '', outcome: 'running', hardMin: hardMin || 120 }
|
|
104
|
+
if (continuable) e.continuable = true
|
|
105
|
+
if (resume) e.resume = true
|
|
106
|
+
t.runs.push(e)
|
|
128
107
|
if (t.runs.length > 20) t.runs = t.runs.slice(-20)
|
|
129
108
|
return { ok: true } // mutateLocked 契约:回调返回 null/undefined 则跳过写盘——必须显式返回非空值,否则 runs 永不落盘
|
|
130
109
|
})
|
|
131
110
|
} catch (e) { console.error('[task-board] recordRunHistory failed:', String(e)) }
|
|
132
111
|
}
|
|
133
|
-
async function closeRunHistory(sid, taskId, runId, outcome) {
|
|
112
|
+
async function closeRunHistory(sid, taskId, runId, outcome, flags) {
|
|
134
113
|
try {
|
|
135
114
|
await mutateLocked(sid, function (d) {
|
|
136
115
|
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
137
116
|
if (!t || !Array.isArray(t.runs)) return null // 找不到任务/无 runs:返回 null 不写盘
|
|
138
117
|
for (var i = t.runs.length - 1; i >= 0; i--) {
|
|
139
|
-
if (t.runs[i].id === runId) {
|
|
118
|
+
if (t.runs[i].id === runId) {
|
|
119
|
+
if (t.runs[i].outcome === 'running') { // 幂等②:只关「还开着」的那条,重入不覆盖结局/endedAt
|
|
120
|
+
t.runs[i].outcome = outcome; t.runs[i].endedAt = new Date().toISOString()
|
|
121
|
+
// flags.noResume:子会话不可信(interrupt 抛错 / 拿不到可授权的活父 Agent)→
|
|
122
|
+
// 这条 run 永久失去续跑资格(resumeTarget 据此回退 fresh spawn,绝不去空唤醒)。
|
|
123
|
+
if (flags && flags.noResume) t.runs[i].noResume = true
|
|
124
|
+
}
|
|
125
|
+
break
|
|
126
|
+
}
|
|
140
127
|
}
|
|
141
128
|
return { ok: true } // 同上:显式返回非空值触发写盘
|
|
142
129
|
})
|
|
143
130
|
} catch (e) { console.error('[task-board] closeRunHistory failed:', String(e)) }
|
|
144
131
|
}
|
|
145
132
|
|
|
133
|
+
// ===== 续跑资格判定(卡2):纯读 t.runs,零 IO =====
|
|
134
|
+
// 从最后一条 Worker run 倒着看,只有「该 run 是 continuable(持久子会话,可冷复活)+ 结局是超时/失败
|
|
135
|
+
// + 未被标记 noResume」才够格——返回可唤醒的 childId,否则返回 ''(不够格)。
|
|
136
|
+
// 一次性 run 没有 continuable 标记(没有可唤醒的会话),故天然零命中:回退开关下整条续跑路径逐字不生效。
|
|
137
|
+
// 只看**最后一条 Worker run**:更早的 run 即使失败,其会话也已被后续 run 取代(续跑要接着最新断点)。
|
|
138
|
+
function resumeTarget(t) {
|
|
139
|
+
if (!t || !Array.isArray(t.runs)) return ''
|
|
140
|
+
for (var i = t.runs.length - 1; i >= 0; i--) {
|
|
141
|
+
var r = t.runs[i]
|
|
142
|
+
if (!r || r.role !== 'worker') continue
|
|
143
|
+
if (r.continuable !== true || r.noResume === true) return ''
|
|
144
|
+
if (r.outcome !== 'timeout/error' && r.outcome !== 'incomplete') return ''
|
|
145
|
+
return String(r.id || '')
|
|
146
|
+
}
|
|
147
|
+
return ''
|
|
148
|
+
}
|
|
149
|
+
// 「第 N 次尝试」的 N:该任务已有的 Worker run 条数 + 本次。t.runs 被 20 条上限裁剪或老卡没有 runs[]
|
|
150
|
+
// 时退化用 retryCount + 1(口径略粗但方向一致)。只用于续跑文案,不参与任何状态机判断。
|
|
151
|
+
function workerAttemptNo(t) {
|
|
152
|
+
var n = 0
|
|
153
|
+
if (t && Array.isArray(t.runs)) for (var i = 0; i < t.runs.length; i++) { if (t.runs[i] && t.runs[i].role === 'worker') n++ }
|
|
154
|
+
if (!n) n = (t && t.retryCount) || 0
|
|
155
|
+
return n + 1
|
|
156
|
+
}
|
|
157
|
+
// continuable 子会话日志的当前字节数(续跑基线):续跑轮结算时只读这之后的字节,从而只认
|
|
158
|
+
// 「本轮新写的助手文本」。护栏理由:子会话是同一个会话,上一轮(被中断那次)的助手文本仍在日志里——
|
|
159
|
+
// 若续跑轮一个字都没产出,绝不能把上一轮的残留文本当成「本轮交付物」推进 verifying(那是假完成)。
|
|
160
|
+
// 读不到日志返回 0 → 退化为「按整段日志读」(与卡1 同口径),不因日志缺失改变结算语义。
|
|
161
|
+
function logSizeOf(childId) {
|
|
162
|
+
try { var log = findRunLog(childId, sessionsRoot); if (!log) return 0; return statSync(log).size || 0 } catch (_) { return 0 }
|
|
163
|
+
}
|
|
164
|
+
|
|
146
165
|
async function spawnOneShot(sid, t, role) {
|
|
147
166
|
var subagents = ctx.subagents; if (!subagents) return null
|
|
148
167
|
// 兜底保留(真异常时有用):poolCycle 入口已有 root 存活早闸门,正常派发路径不会到达这里;
|
|
@@ -154,24 +173,24 @@ export function createDispatch(ctx, state, deps) {
|
|
|
154
173
|
if (role === 'verifier') { modelOverride = (typeof dsnap.verifierModel === 'string' && dsnap.verifierModel.trim()) ? dsnap.verifierModel.trim() : ''; if (modelOverride && badModels[modelKey(sid, modelOverride)]) { console.error('[task-board] model ' + modelOverride + ' circuited, using parent model'); modelOverride = '' } }
|
|
155
174
|
else if (role === 'worker') { modelOverride = (typeof dsnap.workerModel === 'string' && dsnap.workerModel.trim()) ? dsnap.workerModel.trim() : ''; if (modelOverride && badModels[modelKey(sid, modelOverride)]) { console.error('[task-board] model ' + modelOverride + ' circuited, using parent model'); modelOverride = '' } }
|
|
156
175
|
var pack = ''
|
|
157
|
-
try { pack =
|
|
176
|
+
try { pack = readContextPack(t) } catch (e) {
|
|
158
177
|
console.error('[task-board] context pack read failed:', String(e))
|
|
159
|
-
//
|
|
178
|
+
// 调研门禁④:组装清单失败落卡(t.lastError,复用详情页「最近失败」行展示机制),不再只沉在
|
|
160
179
|
// host 控制台——主窗口排查「Worker 为什么没拿到预研材料」不用翻日志。
|
|
161
180
|
// mutateLocked 契约:回调返回 null/undefined 跳过写盘——找到任务才返回非空值;skipKick 免一次无谓 poolCycle。
|
|
162
181
|
try { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === t.id }); if (!t2) return null; t2.lastError = ('contextPack 读取失败: ' + String(e)).slice(0, 300); return t2 }, true) } catch (_) {}
|
|
163
182
|
}
|
|
164
|
-
// user prompt 只留一行指引,内容走上下文注入区块
|
|
165
|
-
var packNote = pack ? '本任务附带主窗口预研文件,已通过「上下文注入」区提供(含文件完整内容),直接基于其内容工作,不要重复读取这些文件。' : ''
|
|
166
183
|
// prompt 三态:worker / verifier / hook(hooks=agent run:pre 与 post 共用 buildHookPrompt,
|
|
167
184
|
// 由 phase 决定契约文案——二者都是挂在 epic 上的一次性真实 agent 运行)。
|
|
185
|
+
// pack = 瘦身清单本体(调研笔记全文 + 文件清单),由 buildWorkerPrompt/buildVerifierPrompt 直接拼进
|
|
186
|
+
// 首条 prompt 一次性注入——不再是「一行指引 + 随快照每轮重发的上下文注入区块」。
|
|
168
187
|
// hook 分支现读一次看板只为拿子任务清单;读失败退化为空清单(prompt 仍成立,绝不因此不 spawn)。
|
|
169
188
|
var promptText
|
|
170
189
|
if (role === 'hook-pre' || role === 'hook-post') {
|
|
171
190
|
var kids = []
|
|
172
191
|
try { var hsnap = await rt(sid); kids = gsb(t.id, (hsnap && hsnap.tasks) || []) } catch (_) {}
|
|
173
192
|
promptText = buildHookPrompt(t, role === 'hook-pre' ? 'pre' : 'post', kids)
|
|
174
|
-
} else promptText = role === 'worker' ? buildWorkerPrompt(t,
|
|
193
|
+
} else promptText = role === 'worker' ? buildWorkerPrompt(t, pack, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, pack)
|
|
175
194
|
var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: promptText }], parent: parent, signal: makeSignal() }
|
|
176
195
|
if (modelOverride) {
|
|
177
196
|
// list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
|
|
@@ -180,51 +199,221 @@ export function createDispatch(ctx, state, deps) {
|
|
|
180
199
|
if (slash > 0) req.agentOptions = { provider: modelOverride.slice(0, slash), model: modelOverride.slice(slash + 1) }
|
|
181
200
|
else req.agentOptions = { model: modelOverride }
|
|
182
201
|
}
|
|
202
|
+
// ===== spawn 两条路径(task-muw5gnhv 卡1:可续跑 Worker + 回退开关)=====
|
|
203
|
+
// 开关开(缺省)→ Worker 走 continuable:subagents.startContinuable 建**持久子会话**
|
|
204
|
+
// (返回 {childId, messageId},无 run.result 承诺),turn 结算改由 host 事件
|
|
205
|
+
// agent/status 的 running→idle 触发 settleWorker(卡2 才在此基础上做续跑/超时 interrupt)。
|
|
206
|
+
// 开关关 / verifier / hook run → 逐字回退旧的 subagents.start()(一次性 run,run.result 结算)。
|
|
207
|
+
// 模型覆盖的回退语义两条路径共用:带 agentOptions 首次失败 → 去掉覆盖用父级模型再试一次
|
|
208
|
+
// (provider 的 prepareContinuable/start 都在第一步就校验模型,失败时都不会留下半建子会话)。
|
|
209
|
+
var c = cfg(dsnap)
|
|
210
|
+
var useContinuable = role === 'worker' && c.workerContinuable !== false
|
|
183
211
|
var run
|
|
184
|
-
var
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
212
|
+
var childId = ''
|
|
213
|
+
try {
|
|
214
|
+
if (useContinuable) {
|
|
215
|
+
var cs = await subagents.startContinuable({ provider: providerName, label: req.label, request: req, signal: req.signal })
|
|
216
|
+
childId = String(cs && cs.childId || '')
|
|
217
|
+
if (!childId) throw new Error('startContinuable 未返回 childId')
|
|
218
|
+
} else {
|
|
219
|
+
run = await subagents.start(providerName, req)
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
if (modelOverride) {
|
|
223
|
+
console.error('[task-board] model override failed, fallback to parent model:', String(e))
|
|
224
|
+
delete req.agentOptions
|
|
225
|
+
try {
|
|
226
|
+
if (useContinuable) {
|
|
227
|
+
var cs2 = await subagents.startContinuable({ provider: providerName, label: req.label, request: req, signal: req.signal })
|
|
228
|
+
childId = String(cs2 && cs2.childId || '')
|
|
229
|
+
if (!childId) throw new Error('startContinuable 未返回 childId')
|
|
230
|
+
} else run = await subagents.start(providerName, req)
|
|
231
|
+
} catch (e2) { console.error('[task-board] spawn ' + role + ' failed:', String(e2)); return null }
|
|
232
|
+
} else { console.error('[task-board] spawn ' + role + ' failed:', String(e)); return null }
|
|
191
233
|
}
|
|
192
|
-
|
|
193
|
-
|
|
234
|
+
// rec 统一形态:id = 该次 run 的子会话 id(continuable 是 childId,一次性是 run.id——
|
|
235
|
+
// 会话 id 语义不变,t.runs[].id / claimedBy / verifierRun / poolStatus.runId / 详情页跳转全部照旧)。
|
|
236
|
+
// rec.run 只在一次性路径存在(dispose/result 的来源);continuable 路径 rec.continuable=true,
|
|
237
|
+
// 用 rec.childId 定位 host 事件、dispose 走 drain(卡1 先不 interrupt,见 settleContinuable)。
|
|
238
|
+
var rec = useContinuable
|
|
239
|
+
? { id: childId, childId: childId, continuable: true, ran: false, run: null, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
|
|
240
|
+
: { id: String(run.id), run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
|
|
194
241
|
runsFor(sid)[t.id] = rec
|
|
195
|
-
if (pack) packByChild[String(run.id)] = pack
|
|
196
242
|
if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
|
|
197
|
-
dispatchedEver[sid][String(
|
|
243
|
+
dispatchedEver[sid][String(rec.id)] = true
|
|
198
244
|
// 历史会话留档:每次派发都追加一条 {role,id,at,model},任务完成后仍可回看
|
|
199
|
-
// 各阶段(含重试的第 1/2/3 次 Worker)会话——否则 claimedBy/verifierRun
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
245
|
+
// 各阶段(含重试的第 1/2/3 次 Worker)会话——否则 claimedBy/verifierRun 只留最后一次。
|
|
246
|
+
// **必须 await**(task-muwkhqf8):上报通道的收尾(settleReportedRun → closeRunHistory)要求在
|
|
247
|
+
// 「记录已落卡」之后才能关账——fire-and-forget 下极短任务(worker 秒级 board_report)会撞上竞态:
|
|
248
|
+
// 关账先落地 → 找不到条目 → closeRunHistory 空转,run 记录依旧停在 running。
|
|
249
|
+
await recordRunHistory(sid, t.id, role, String(rec.id), modelOverride, c.hardTimeoutMin, !!rec.continuable)
|
|
250
|
+
// ===== 两级超时臂(软提醒 + 硬超时结算):本体在 armTimeouts,fresh spawn 与续跑共用 =====
|
|
251
|
+
armTimeouts(sid, rec, t, c)
|
|
252
|
+
return rec
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ===== 两级超时:软超时只提醒主窗口(由人决定继续等待或终止),硬超时兜底结算 =====
|
|
256
|
+
// 一次性 run 没有看门狗:完全依赖人工决策时,人不在线挂死的 run 会永久占用并发位,
|
|
257
|
+
// 所以保留硬上限作为最后防线(默认 120min,可配置)。
|
|
258
|
+
// 两条 spawn 路共用这条臂(卡2 从 spawnOneShot 抽出):续跑轮也必须挂——否则冷复活的子会话挂死
|
|
259
|
+
// 会永久占用 Worker 并发位,且再没有下一次重派。
|
|
260
|
+
// 硬超时到期本身只是「失败结算」的触发器(语义与一次性路径一致),收尾动作按 rec 分流
|
|
261
|
+
// (见 settleRun):continuable → interrupt 留存(重派时 sendMessage 冷复活续跑);一次性 → run.dispose()。
|
|
262
|
+
// ===== 超时臂的「摘除」(task-muwkhqf8 回归修复)=====
|
|
263
|
+
// 病根:两条臂此前**只能靠自己到期落定或 rec.settled 旗空转**——结算完成后软臂那条递归链仍在
|
|
264
|
+
// 每 30min 醒一次刷「worker 已运行 N 分钟仍未完成」的假告警(实测对已 board_report 完成的 run 误报),
|
|
265
|
+
// 硬臂也只是被(未取消的)setTimeout 兜住、到期才对一条早已结算的闲置会话补一枪 interrupt。
|
|
266
|
+
// 修法:软臂改回调式 ctx.timer.timeout(cb, ms)(返回 dispose 闭包,可真清);硬臂旁挂一个可 reject 的
|
|
267
|
+
// 闸门,摘除时 reject 掉 → withTimeout 的 race 立刻落定,结算链收口,到期不再有补枪。
|
|
268
|
+
// 幂等:disarm 可能被多条入口重复调用(事件通道 / 上报通道 / 硬超时路径自身),一律 no-op 安全。
|
|
269
|
+
// 兼容:老宿主/测试桩的 ctx.timer 可能只实现 promise 形态(返回 thenable 而非 dispose 函数)——
|
|
270
|
+
// 此时没有可清的 handle,退化为「靠 rec.settled 旗空转」,行为与本次改造前逐字一致。
|
|
271
|
+
function armTimeouts(sid, rec, t, c) {
|
|
204
272
|
var startedAt = rec.startedAt
|
|
205
273
|
var softMs = c.softTimeoutMin * 60000
|
|
206
274
|
var hardMs = c.hardTimeoutMin * 60000
|
|
207
275
|
function finish(res, err) { if (rec.settled) return; rec.settled = true; settleRun(sid, rec, res, err) }
|
|
208
|
-
|
|
276
|
+
function disarmTimeouts() {
|
|
277
|
+
if (rec.timersDisarmed) return
|
|
278
|
+
rec.timersDisarmed = true
|
|
279
|
+
// 软臂:清掉待到期的那次递归 timeout(链上的下一次由 rec.timersDisarmed 早退拦住,不再续挂)
|
|
280
|
+
try { if (typeof rec.softDispose === 'function') rec.softDispose() } catch (_) {}
|
|
281
|
+
// 硬臂:reject 掉旁挂闸门 → withTimeout 的 race 立即落定为失败;rec.settled 已立时 finish 直接放行
|
|
282
|
+
try { if (typeof rec.abort === 'function') rec.abort() } catch (_) {}
|
|
283
|
+
}
|
|
284
|
+
rec.disarm = disarmTimeouts // 暴露给上报通道(board_report 完成即收尾)与事件通道结算路径
|
|
285
|
+
// 软臂:回调式 ctx.timer.timeout(cb, ms)(真实 ctx.timer 返回 dispose 闭包)→ 可被 disarm 真清掉;
|
|
286
|
+
// 老桩的 promise 形态(返回 thenable)退化为 .then 语义——与本次改造前的行为逐字一致。
|
|
287
|
+
function softSchedule() {
|
|
209
288
|
var tm = ctx.timer; if (!tm) return
|
|
210
|
-
tm.timeout(
|
|
211
|
-
if (rec.settled) return
|
|
289
|
+
var h = tm.timeout(function () {
|
|
290
|
+
if (rec.timersDisarmed || rec.settled) return
|
|
212
291
|
var mins = Math.round((Date.now() - startedAt) / 60000)
|
|
213
292
|
// 带 taskId:投递前会按任务现状复查,任务已完成/落定的过期告警直接丢弃(避免误报)
|
|
214
|
-
pushSysNote(sid, '⏱ 任务「' + t.title + '」的 ' + role + '(' + t.id + ')已运行 ' + mins + ' 分钟仍未完成——如属正常长任务可忽略;需要干预可在看板详情页「立即终止」(硬超时 ' + c.hardTimeoutMin + ' 分钟后将自动终止并重试)', t.id)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
293
|
+
pushSysNote(sid, '⏱ 任务「' + t.title + '」的 ' + rec.role + '(' + t.id + ')已运行 ' + mins + ' 分钟仍未完成——如属正常长任务可忽略;需要干预可在看板详情页「立即终止」(硬超时 ' + c.hardTimeoutMin + ' 分钟后将自动终止并重试)', t.id)
|
|
294
|
+
if (rec.timersDisarmed || rec.settled) return
|
|
295
|
+
softSchedule() // 持续提醒直到结算或硬超时(下一环同样可被 disarm 清掉)
|
|
296
|
+
}, softMs)
|
|
297
|
+
if (typeof h === 'function') rec.softDispose = h
|
|
298
|
+
else if (h && typeof h.then === 'function') { h.then(function () { if (!rec.timersDisarmed && !rec.settled) softSchedule() }).catch(function () {}) }
|
|
299
|
+
}
|
|
300
|
+
;(function softArm() { softSchedule() })()
|
|
301
|
+
// continuable 没有 run.result:用永不落定的 Promise 占位——结算唯一入口是 agent/status 的 idle
|
|
302
|
+
// (硬超时那条臂由 withTimeout 提供,语义与一次性路径一致:到期走失败结算)。
|
|
303
|
+
// 测试钩子 deps.continuableResult:单测要验证「continuable 路径的失败/超时结算」时替换这个占位
|
|
304
|
+
// (生产不注入 → 保持永不落定)。
|
|
305
|
+
var resultP = rec.continuable ? (typeof deps.continuableResult === 'function' ? deps.continuableResult(rec) : new Promise(function () {})) : rec.run.result
|
|
306
|
+
// 可摘除硬臂:abort 闸门先落定 → withTimeout 的 race 立刻结束,结算后不再对闲置会话补枪。
|
|
307
|
+
// 兜底两处(均为老环境/老桩):AbortController 缺席 → 退化为裸 withTimeout(逐字不变);
|
|
308
|
+
// AbortSignal.reason 缺席(Node <17.2)→ abort 落定为无 reason 的 AbortError(老 Node 的既有形状)。
|
|
309
|
+
var abortableP = resultP
|
|
310
|
+
try {
|
|
311
|
+
if (typeof AbortController === 'function') {
|
|
312
|
+
var ac = new AbortController()
|
|
313
|
+
// ⚠️ 刻意**不带 reason**(无参 abort):Node 会把 abort reason 变成 signal 内部的未处理拒绝
|
|
314
|
+
// (AbortSignal 的 thenable),带 reason 时整个进程会因 unhandled rejection 崩掉——本实现踩过的坑。
|
|
315
|
+
// 落定值由旁挂 promise 自己给(见下),不依赖 signal.reason。
|
|
316
|
+
rec.abort = function () { ac.abort() } // finish 已由 rec.settled 挡住,这条不触发结算
|
|
317
|
+
abortableP = new Promise(function (res, rej) {
|
|
318
|
+
// 摘除(abort)与「abort 之后再挂」都落成同一个失败值:finish 读 rec.settled 后直接 return,不结算。
|
|
319
|
+
function onAbort() { rej(new Error('settled')) }
|
|
320
|
+
if (ac.signal.aborted) { onAbort(); return }
|
|
321
|
+
ac.signal.addEventListener('abort', onAbort, { once: true })
|
|
322
|
+
Promise.resolve(resultP).then(res, rej)
|
|
323
|
+
})
|
|
324
|
+
// ⚠️ 必须就地挂一个空 catch:某些宿主/测试桩的 withTimeout 在**另一路先行落定**(例如硬超时本身
|
|
325
|
+
// 立即 reject)时不会把本 promise 接进 race 的处理器——那样 abort 落定就成了「无处理者的拒绝」,
|
|
326
|
+
// 整个进程会被 unhandled rejection 干掉(本实现踩过的坑)。空 catch 只标记「有人处理」,
|
|
327
|
+
// 不改变 race 的结算值。
|
|
328
|
+
abortableP.catch(function () {})
|
|
329
|
+
} else rec.abort = null
|
|
330
|
+
} catch (_) { rec.abort = null; abortableP = resultP }
|
|
331
|
+
withTimeout(abortableP, hardMs, rec.role + ':' + t.id).then(function (res) { finish(res, null) }).catch(function (e) { finish(null, e) })
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ===== 重派续跑(卡2):命中「上次 continuable Worker run 结局=超时/失败」的 pending 任务 =====
|
|
335
|
+
// 不 spawn 新 Worker,改 subagents.sendMessage(活父 Agent, childId, 断点续跑指令):子会话从持久化
|
|
336
|
+
// **冷复活**,带着自己上一轮的全部上下文(读过什么、改到哪、哪些命令跑通了)接着干——
|
|
337
|
+
// 超时重派不再从零开始。指令是薄框架文案:只要求「盘点工作树 + 从断点继续」,具体怎么做由它判断。
|
|
338
|
+
// 三态返回:{ rec } 续跑成功 / { fallback } 续跑不可用(调用方落回 fresh spawn,并把原因写进任务消息)/
|
|
339
|
+
// null 压根不适用(无可续跑 run / 宿主没有 sendMessage 能力)——此时调用方走原 fresh spawn,
|
|
340
|
+
// 一次性回退开关(workerContinuable=false)下的行为因此逐字不变。
|
|
341
|
+
// 授权形状取 dsh-subagent 的 SubagentSendMessageOptions:{ signal }(sender 必须是**活的直接父 Agent**,
|
|
342
|
+
// 服务端按邻接关系校验;子会话不在线则从持久化冷复活,不可复活时 reject → 这里转成 fallback)。
|
|
343
|
+
async function tryResumeWorker(sid, t) {
|
|
344
|
+
var subagents = ctx.subagents
|
|
345
|
+
if (!subagents || typeof subagents.sendMessage !== 'function') return null
|
|
346
|
+
var childId = resumeTarget(t)
|
|
347
|
+
if (!childId) return null
|
|
348
|
+
var parent = rootForSession(sid)
|
|
349
|
+
if (!parent) return { fallback: 'root 会话不在线,无法授权续跑' }
|
|
350
|
+
// cfg 提前取好:sendMessage 返回后要**零 await** 地登记 rec(事件通道必须马上能看到它,
|
|
351
|
+
// 否则冷复活子会话的 running→idle 可能在注册前跑完,结算就只能干等硬超时)。
|
|
352
|
+
var c = cfg(await rt(sid))
|
|
353
|
+
var attempt = workerAttemptNo(t)
|
|
354
|
+
var text = '【断点续跑】你之前执行此任务被中断(第 ' + attempt + ' 次尝试)。先盘点当前工作树状态(git status/diff)与你已完成的步骤,从断点继续;任务契约与验收标准见上文历史。吃不准就 board_report escalate。'
|
|
355
|
+
// ===== 续跑的权威锚点(task-muwox2ii,反馈 n-muwn5s28xcfd 活体三次实证)=====
|
|
356
|
+
// 病根:冷复活子会话带回来的是**它自己上一轮**的全部上下文——原始任务契约(如「上报歧义后不要做
|
|
357
|
+
// 其他事」)在它眼里就是最高指令;而主窗口的裁决答案只落在卡的 t.messages 里,子会话历史里根本没有。
|
|
358
|
+
// 旧文案只说「任务契约与验收标准见上文历史」,等于把权威**锚死在历史契约**上 → 续跑的 Worker 忠实
|
|
359
|
+
// 盘点断点后继续按旧契约空转,收不了口、耗到硬超时(同一任务 fresh spawn 时反而正常:新 Worker 的
|
|
360
|
+
// 首条 prompt 由 buildWorkerPrompt 带上了 t.messages,可见「messages 注入生效」就是二者的唯一差异)。
|
|
361
|
+
// 修法①:把卡上消息**原文**随续跑指令一起投过去——与 fresh spawn 同心同源(同用 core.buildMessages,
|
|
362
|
+
// 截断口径一致),子会话不必猜、也不需要回看板就能拿到最新裁决/干预;
|
|
363
|
+
// 修法②:末尾立优先级声明——最新指示优先于历史契约,冲突以最新为准(措辞显式给出反例,防止模型
|
|
364
|
+
// 把「契约说不要做其他事」读成仍然生效)。
|
|
365
|
+
var msgs = ''
|
|
366
|
+
try { msgs = core.buildMessages(t) } catch (_) {}
|
|
367
|
+
if (msgs) text += '\n\n该任务的最新消息(主窗口裁决/高优干预/驳回理由等,请务必遵循):\n' + msgs
|
|
368
|
+
text += '\n\n【优先级声明】本次消息与上面 messages 里的最新裁决/干预,优先于上文历史中的原始任务契约;两者冲突时以最新指示为准(例如原始契约写「上报歧义后不要做其他事」,而最新裁决要求「直接上报完成」,就以「上报完成」为准)。'
|
|
369
|
+
try {
|
|
370
|
+
await subagents.sendMessage(parent, childId, [{ type: 'text', text: text }], { signal: makeSignal() })
|
|
371
|
+
} catch (e) {
|
|
372
|
+
// NOT_RESUMABLE / 邻接校验失败 / 任何错:一律回退 fresh spawn(本函数不抛,调用方按 rec 为 null 处理)。
|
|
373
|
+
return { fallback: String(e) }
|
|
374
|
+
}
|
|
375
|
+
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) }
|
|
376
|
+
runsFor(sid)[t.id] = rec
|
|
377
|
+
if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
|
|
378
|
+
dispatchedEver[sid][childId] = true
|
|
379
|
+
armTimeouts(sid, rec, t, c)
|
|
380
|
+
// runs[] 追加一条**续跑记录**(role:'worker', id:childId 与原 run 同 id=同一会话, resume:true):
|
|
381
|
+
// 详情页可区分首派/续跑,且它成为续跑资格判定的最新依据。
|
|
382
|
+
// 这条**必须 await**:竞速下(冷复活子会话立刻 idle)closeRunHistory 可能先落地,
|
|
383
|
+
// 那会找不到条目、结局留在 running → 该 run 永久失去续跑资格。
|
|
384
|
+
await recordRunHistory(sid, t.id, 'worker', childId, '', c.hardTimeoutMin, true, true)
|
|
385
|
+
return { rec: rec }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// run 结算:保证收尾;工具通道(board_report/board_verdict)已推进状态的话文本路径跳过
|
|
389
|
+
// ===== 结算收尾三件套(task-muwkhqf8)=====
|
|
390
|
+
// 无论从哪条入口进来(事件通道 settleContinuable / 上报通道 board_report),run 记录的收尾动作
|
|
391
|
+
// 必须一致:①关 run 结局(closeRunHistory)②usage 落账(seq 水位线增量)③摘除软/硬超时臂。
|
|
392
|
+
// 三条各自幂等:重复结算(idle 事件重入 / 上报与事件双通道先后到达)不二次关账、不二次记账。
|
|
393
|
+
async function settleRunRecord(sid, rec, opts) {
|
|
394
|
+
// ① 结局落卡:failed → 有 err 记 timeout/error,无 err 记 incomplete(与一次性路径口径一致);
|
|
395
|
+
// 成功 → completed。noResume 旗随 rec.resumeBlocked(interrupt 失败)落卡。
|
|
396
|
+
try {
|
|
397
|
+
await closeRunHistory(sid, rec.taskId, String(rec.id), opts.outcome, rec.continuable ? { noResume: rec.resumeBlocked === true } : null)
|
|
398
|
+
} catch (e) { console.error('[task-board] closeRunHistory 收尾失败 (task ' + rec.taskId + '):', String(e)) }
|
|
399
|
+
// ② usage 落账:读该次 run 的 v4 日志聚合(水位线增量),累加到任务。放在状态推进之后:
|
|
400
|
+
// 统计是附加信息,读日志失败/无 usage 时静默跳过,绝不影响结算语义。
|
|
401
|
+
// Worker 失败重试、驳回重做都会各走一次 settleRun,因此多轮消耗天然累加(runs 计数)。
|
|
402
|
+
try { await accumulateRunUsage(sid, rec) } catch (e) { console.error('[task-board] usage 收尾失败 (task ' + rec.taskId + '):', String(e)) }
|
|
403
|
+
// ③ 摘除两级超时臂:软臂不再刷假告警,硬臂不再对已结算的闲置会话补一枪 interrupt。
|
|
404
|
+
try { if (typeof rec.disarm === 'function') rec.disarm() } catch (_) {}
|
|
220
405
|
}
|
|
221
406
|
|
|
222
|
-
// run 结算:保证 dispose;工具通道(board_report/board_verdict)已推进状态的话文本路径跳过
|
|
223
407
|
async function settleRun(sid, rec, res, err) {
|
|
224
408
|
if (runsFor(sid)[rec.taskId] !== rec) return // 已被 terminate 等路径处理
|
|
225
409
|
delete runsFor(sid)[rec.taskId]
|
|
226
|
-
|
|
227
|
-
|
|
410
|
+
// 收尾分路(卡2):一次性 run 走 dispose(既有语义逐字不变);continuable 走 interrupt——
|
|
411
|
+
// **只打断当前 turn,不销毁子会话**(Activation/未认领收件箱/已发布后代全部保留),
|
|
412
|
+
// idle 后仍可被 sendMessage 冷复活,这正是重派续跑的前提。
|
|
413
|
+
try {
|
|
414
|
+
if (rec.continuable) await endContinuable(sid, rec)
|
|
415
|
+
else await rec.run.dispose()
|
|
416
|
+
} catch (_) {}
|
|
228
417
|
var output = outputText(res)
|
|
229
418
|
var failed = !!err || (res && res.stopReason && res.stopReason !== 'completed')
|
|
230
419
|
var errText = err ? String(err) : (res && (res.diagnostic || res.stopReason) || '')
|
|
@@ -233,12 +422,92 @@ export function createDispatch(ctx, state, deps) {
|
|
|
233
422
|
else if (rec.role === 'hook-pre' || rec.role === 'hook-post') await settleHook(sid, rec, output, failed, errText)
|
|
234
423
|
else await settleVerifier(sid, rec, output, failed, errText)
|
|
235
424
|
} catch (e) { console.error('[task-board] settle ' + rec.role + ' failed (task ' + rec.taskId + '):', String(e)) }
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
425
|
+
// 历史会话留档 + usage + 超时臂收尾:三件套抽到 settleRunRecord(上报通道共用同一实现)。
|
|
426
|
+
// 结局同时是**续跑资格**的唯一依据(continuable + timeout/error|incomplete → 下次重派改续跑),
|
|
427
|
+
// noResume 旗(interrupt 失败)一并落卡,让重派直接回退 fresh spawn。
|
|
428
|
+
// ⚠️ 位置在状态推进**之后**:rec.resumeBlocked 由 endContinuable 在推进前写好,这里读到的才是终值。
|
|
429
|
+
await settleRunRecord(sid, rec, { outcome: failed ? (err ? 'timeout/error' : 'incomplete') : 'completed' })
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ===== 可续跑 Worker 的 turn 结算(task-muw5gnhv 卡1;卡2 加续跑基线)=====
|
|
433
|
+
// 与一次性路径的关系:任务推进语义**完全复用** settleWorker(board_report 工具优先、文本兜底同构),
|
|
434
|
+
// 差别只在「怎么知道 turn 完了」与「怎么收尾」:
|
|
435
|
+
// · 一次性:run.result 落定 → settleRun(res,err)(有结构化结果/失败原因);
|
|
436
|
+
// · continuable:没有 run.result,只能靠 host 事件 agent/status 的 running→idle 观测 turn 结束
|
|
437
|
+
// (见 onAgentStatus)。因此这里没有 res:输出文本从子会话 v4 日志尾部的 assistant/message 兜底读;
|
|
438
|
+
// 日志读不到且文本为空时按失败结算(走 pending 重试),**绝不当成「空完成」推进到 verifying**——
|
|
439
|
+
// 否则一次「没跑起来就 idle」的事件会把任务误判为有交付物。
|
|
440
|
+
// 收尾三件套(关 run 结局 / usage 落账 / 摘超时臂)不在本函数里单独写一遍,而是随 settleRun →
|
|
441
|
+
// settleRunRecord 一起做——它与上报通道(board_report → settleReportedRun)共用同一实现
|
|
442
|
+
// (task-muwkhqf8):两条入口只是「谁来触发」,收尾口径必须只有一套。
|
|
443
|
+
async function settleContinuable(sid, rec) {
|
|
444
|
+
if (runsFor(sid)[rec.taskId] !== rec) return // 已被 terminate/硬超时等路径认领
|
|
445
|
+
var text = ''
|
|
446
|
+
// 续跑轮(rec.baselineBytes>0)只读基线之后的字节:本轮没写东西就是空文本 → 按失败重排,
|
|
447
|
+
// 不会把上一轮(被中断那次)残留的助手文本冒充成本轮交付物(同一条失败臂,语义不变)。
|
|
448
|
+
try { text = childSessionOutput(rec.childId, rec.baselineBytes) } catch (e) { console.error('[task-board] continuable 输出读取失败 (' + rec.childId + '):', String(e)) }
|
|
449
|
+
if (!text.trim()) console.error('[task-board] continuable child ' + rec.childId + ' idle 但未读到 assistant 文本,按失败结算(任务 ' + rec.taskId + ')')
|
|
450
|
+
await settleRun(sid, rec, { output: [{ type: 'text', text: text }], stopReason: text.trim() ? 'completed' : 'error' }, null)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// continuable 子会话的收尾(卡2:**interrupt 留存,不销毁**)——
|
|
454
|
+
// 失败/硬超时结算时只发一个取消信号打断当前 turn:Activation、未认领的收件箱、已发布后代全部保留,
|
|
455
|
+
// 子会话 idle 后仍能被 sendMessage 冷复活(Step2 续跑的前提);一次性路径的 dispose 不动。
|
|
456
|
+
// authority 形状取 dsh-subagent 的 SubagentInterruptAuthority:{kind:'ancestor', agent: 活的直接父 Agent};
|
|
457
|
+
// 该调用同步 fire-and-return(发完取消信号即返回,目标可能到下一个可观察点才真正停下)。
|
|
458
|
+
// interrupt 失败(会话已死 / 权限不符 / 宿主无此能力)→ **绝不阻断结算**:任务照常回 pending 重排;
|
|
459
|
+
// 只给 rec 立 resumeBlocked 旗 → 该 run 落 noResume 标记,重派时直接 fresh spawn(不空唤醒、不卡死)。
|
|
460
|
+
async function endContinuable(sid, rec) {
|
|
461
|
+
if (!rec || !rec.childId) return rec
|
|
462
|
+
var subagents = ctx.subagents
|
|
463
|
+
if (!subagents || typeof subagents.interrupt !== 'function') return rec
|
|
464
|
+
var parent = rootForSession(sid)
|
|
465
|
+
if (!parent) { rec.resumeBlocked = true; return rec }
|
|
466
|
+
try {
|
|
467
|
+
subagents.interrupt(rec.childId, { kind: 'ancestor', agent: parent })
|
|
468
|
+
} catch (e) {
|
|
469
|
+
rec.resumeBlocked = true
|
|
470
|
+
console.error('[task-board] interrupt 失败,该子会话不再续跑(重派回退 fresh spawn)child ' + rec.childId + ':', String(e))
|
|
471
|
+
}
|
|
472
|
+
return rec
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 读子会话 v4 日志尾部的助手文本(continuable 路径的文本兜底通道):
|
|
476
|
+
// 与 rpc.mjs agent-activity 同一套 helper/事件形状(assistant/message.content[].text),
|
|
477
|
+
// 只是这里要的是**最后一条完整助手文本**(供 settleWorker 解析分段格式 / [ESCALATE])。
|
|
478
|
+
// 只读尾部 2MB:回复在末尾,整份读大日志会拖慢结算路径;读不到一律返回 ''(调用方按失败处理)。
|
|
479
|
+
// minBytes > 0(续跑轮):只读文件该偏移之后的字节——帧从新到旧,切在半帧上的那一帧由
|
|
480
|
+
// readLogFrames 逐帧 catch 降级丢弃;本轮没产出任何助手文本就返回 ''(走「空文本按失败」)。
|
|
481
|
+
function childSessionOutput(childId, minBytes) {
|
|
482
|
+
if (!childId) return ''
|
|
483
|
+
// 与 logSizeOf/readRunUsage 同源:显式带上 sessionsRoot(生产注入 undefined → 回退 os.homedir(),
|
|
484
|
+
// 与本次改造前逐字一致;单测注入临时目录时才读得到真日志——此前漏传会让「文本兜底」这一路永远读空)。
|
|
485
|
+
var log = findRunLog(childId, sessionsRoot)
|
|
486
|
+
if (!log) return ''
|
|
487
|
+
var tail = 2 * 1024 * 1024
|
|
488
|
+
if (typeof minBytes === 'number' && minBytes > 0) {
|
|
489
|
+
var sz = 0
|
|
490
|
+
try { sz = statSync(log).size || 0 } catch (_) { sz = 0 }
|
|
491
|
+
if (sz <= minBytes) return '' // 续跑基线之后一个字节都没写 → 本轮没有交付物
|
|
492
|
+
tail = sz - minBytes
|
|
493
|
+
}
|
|
494
|
+
var buf = readLogBytes(log, tail)
|
|
495
|
+
if (!buf) return ''
|
|
496
|
+
var frames = readLogFrames(buf, 0)
|
|
497
|
+
var out = ''
|
|
498
|
+
for (var fi = 0; fi < frames.length; fi++) {
|
|
499
|
+
var evs = frames[fi]
|
|
500
|
+
for (var i = 0; i < evs.length; i++) {
|
|
501
|
+
var e = evs[i]; var dta = (e && e.data) || {}
|
|
502
|
+
if (!e || e.type !== 'assistant/message') continue
|
|
503
|
+
var msg = dta.message || {}
|
|
504
|
+
var blocks = Array.isArray(msg.content) ? msg.content : []
|
|
505
|
+
var parts = []
|
|
506
|
+
for (var b = 0; b < blocks.length; b++) { if (blocks[b] && blocks[b].type === 'text' && blocks[b].text) parts.push(String(blocks[b].text)) }
|
|
507
|
+
if (parts.length) out = parts.join('\n') // 帧从新到旧:最后命中覆盖前值 → 得到最新一条助手文本
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return out
|
|
242
511
|
}
|
|
243
512
|
|
|
244
513
|
// 本地日期 key(YYYY-MM-DD):byDay 日账的唯一口径。
|
|
@@ -258,6 +527,54 @@ export function createDispatch(ctx, state, deps) {
|
|
|
258
527
|
return (u.input || 0) + (u.output || 0) + (u.cacheWrite || 0)
|
|
259
528
|
}
|
|
260
529
|
|
|
530
|
+
// ===== usage 增量水位线(卡3 Step1):这一轮该从哪个 seq 之后继续累加 =====
|
|
531
|
+
// 读同一 rec.id 的历史 runs 条目上落的水位线(runs[i].usageSeq = 上次结算时该会话日志的 maxSeq)。
|
|
532
|
+
// 为什么锚在 runs 条目而不是内存表:重启后内存表清零而卡上留档还在,续跑轮才能接着上次水位;
|
|
533
|
+
// 且同一 childId 的多次派发各占一条 runs(resume 轮追加新条目),水位随条目天然分段、互不串账。
|
|
534
|
+
// ⚠️ 倒序找到「同 id 的条目」还不能停:**续跑轮的新条目与旧条目同 id**(同一会话),新条目此时
|
|
535
|
+
// 还没结算过、usageSeq 未设——必须继续往前找第一条**已结算过**(usageSeq > 0)的条目,否则水位线
|
|
536
|
+
// 恒为 0 → 每轮都把整份日志重算一遍(本实现踩过的坑,实测总量双倍 25 而非 15)。
|
|
537
|
+
// 一条都没有(首次结算 / 手写裁剪过的老任务)→ 0 = 全量累加(与本次改造前逐字一致)。
|
|
538
|
+
function seekSeqOf(snap, taskId, runId) {
|
|
539
|
+
try {
|
|
540
|
+
var tasks = (snap && snap.tasks) || []
|
|
541
|
+
for (var ti = 0; ti < tasks.length; ti++) {
|
|
542
|
+
var t = tasks[ti]
|
|
543
|
+
if (!t || t.id !== taskId || !Array.isArray(t.runs)) continue
|
|
544
|
+
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
545
|
+
var r = t.runs[ri]
|
|
546
|
+
if (!r || String(r.id) !== String(runId)) continue
|
|
547
|
+
var w = num0(r.usageSeq)
|
|
548
|
+
if (w > 0) return w
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} catch (_) {}
|
|
552
|
+
return 0
|
|
553
|
+
}
|
|
554
|
+
// 水位线字段兜底成非负数字(脏值/老形态一律当 0 = 全量,宁可多记也不误丢)
|
|
555
|
+
function num0(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
|
|
556
|
+
|
|
557
|
+
// 该 run 记录是否已落过 usage(usageRecorded 旗):倒序找同 id 的条目,见旗即真。
|
|
558
|
+
// 判据为什么要**同一 childId 复用**(而不是只认最新条目):continuable 的续跑轮与首轮共用同一个
|
|
559
|
+
// 子会话 id,closeRunHistory 会把每一轮的条目都更新成终局——「最新那条」在续跑轮结算时正是本轮
|
|
560
|
+
// 新建的那条(usageRecorded 未置,照常记账)。真重复结算(事件通道与上报通道都来/上报后 idle 补到)
|
|
561
|
+
// 时最新条目就是那份已记账的旧条目 → 见旗即跳过。两处语义互斥,同一判据够用,不需要另立新字段。
|
|
562
|
+
function hasUsageRecorded(snap, taskId, runId) {
|
|
563
|
+
try {
|
|
564
|
+
var tasks = (snap && snap.tasks) || []
|
|
565
|
+
for (var ti = 0; ti < tasks.length; ti++) {
|
|
566
|
+
var t = tasks[ti]
|
|
567
|
+
if (!t || t.id !== taskId || !Array.isArray(t.runs)) continue
|
|
568
|
+
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
569
|
+
var r = t.runs[ri]
|
|
570
|
+
if (!r || String(r.id) !== String(runId)) continue
|
|
571
|
+
return r.usageRecorded === true // 同 id 的最新条目就是本轮该结算的那条
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
} catch (_) {}
|
|
575
|
+
return false
|
|
576
|
+
}
|
|
577
|
+
|
|
261
578
|
// 把一次 run 的 token 消耗累加到任务(t.usage):总量/输入/输出/缓存读写 + 按模型小计 + runs 计数 + 日账。
|
|
262
579
|
// 模型小计的 key:优先本次派发显式覆盖的模型(rec.model),否则用日志里记录的会话模型。
|
|
263
580
|
// 日账(byDay):本次 run 整笔记到「结算时刻的本地日」——一次 run 不跨日拆分
|
|
@@ -265,9 +582,22 @@ export function createDispatch(ctx, state, deps) {
|
|
|
265
582
|
// 双指标形态:byDay[day] = { t: total, e: effective }(e 是有效消耗,不含缓存读)。
|
|
266
583
|
// 老数据(number 形态,本轮之前落的日账)只在聚合端兼容:读侧按 { t: n, e: null } 处理,
|
|
267
584
|
// e 不可知就置 null(宁可展示上标 ~ 近似,也不伪造一个「有效值」)。
|
|
585
|
+
// 增量口径(卡3 Step1):结算前先读一次看板取该 run 的历史水位线(seekSeqOf),只把这之后的
|
|
586
|
+
// assistant/message 增量计入;累加时把本份日志的 maxSeq 一并落到该 runs 条目上(下一轮从这继续)。
|
|
587
|
+
// one-shot 路径(每 run 独立日志、永远找得到自己的条目且首次无水位)→ 行为逐字不变:全量累加一次。
|
|
268
588
|
async function accumulateRunUsage(sid, rec) {
|
|
269
589
|
var u = null
|
|
270
|
-
|
|
590
|
+
var since = 0
|
|
591
|
+
try {
|
|
592
|
+
var snap0 = await rt(sid)
|
|
593
|
+
since = seekSeqOf(snap0, rec.taskId, rec.id)
|
|
594
|
+
// 幂等(task-muwkhqf8):该条 run 记录已落过账 → 直接跳过。为什么需要它:continuable 的 run 现在有
|
|
595
|
+
// 两条结算入口(事件通道 + board_report 上报通道),且两条可能在「先读到快照、后持锁写」的窗口里
|
|
596
|
+
// 交错——只靠水位线挡不住同一次结算被记两笔(两次都读到同一旧快照)。旗与累加在同一持有锁回调里
|
|
597
|
+
// 写,读序天然串行。判据只看**同 id 的最新条目**(见 hasUsageRecorded:续跑轮新条目未置旗,照常记账)。
|
|
598
|
+
if (hasUsageRecorded(snap0, rec.taskId, rec.id)) return
|
|
599
|
+
u = readRunUsage(String(rec.id), sessionsRoot, since)
|
|
600
|
+
} catch (_) { u = null }
|
|
271
601
|
if (!u || !u.total) return
|
|
272
602
|
var eff = effectiveOf(u)
|
|
273
603
|
try {
|
|
@@ -299,10 +629,16 @@ export function createDispatch(ctx, state, deps) {
|
|
|
299
629
|
// 每个 run 自带五分量,任意维度都能精确重算(byDay 只是它的一个投影)。
|
|
300
630
|
// 找不到条目就跳过:t.runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,
|
|
301
631
|
// 正常结算路径必存在;只有手写/裁剪过的历史任务会缺,缺了也不该阻断聚合累加。
|
|
632
|
+
// 置位 usageRecorded = 该条 run 已落账(幂等旗,见函数头注释与 hasUsageRecorded):
|
|
633
|
+
// 与累加同一次持有锁写,读侧据此挡住「同一结算被两条入口各记一笔」。
|
|
302
634
|
if (Array.isArray(t.runs)) {
|
|
303
635
|
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
304
|
-
if (String(t.runs[ri] && t.runs[ri].id) === String(rec.
|
|
636
|
+
if (String(t.runs[ri] && t.runs[ri].id) === String(rec.id)) {
|
|
305
637
|
t.runs[ri].usage = { input: u.input || 0, output: u.output || 0, cacheRead: u.cacheRead || 0, cacheWrite: u.cacheWrite || 0, total: u.total || 0 }
|
|
638
|
+
// 水位线落卡(卡3 Step1):本份日志已结算到 maxSeq;续跑轮再结算时只认这之后的帧。
|
|
639
|
+
// 只增不减(max 收敛):异常情况下读到较小值也不让水位倒退(倒退=把已结算帧再记一遍)。
|
|
640
|
+
t.runs[ri].usageSeq = Math.max(num0(t.runs[ri].usageSeq), num0(u.maxSeq))
|
|
641
|
+
t.runs[ri].usageRecorded = true
|
|
306
642
|
break
|
|
307
643
|
}
|
|
308
644
|
}
|
|
@@ -313,6 +649,82 @@ export function createDispatch(ctx, state, deps) {
|
|
|
313
649
|
} catch (e) { console.error('[task-board] usage accumulate failed (task ' + rec.taskId + '):', String(e)) }
|
|
314
650
|
}
|
|
315
651
|
|
|
652
|
+
// ===== 上报通道结算入口(task-muwkhqf8 回归修复的第二条入口)=====
|
|
653
|
+
// 病根:continuable Worker 的 run 记录此前**只有事件通道**会收尾(agent/status 的 running→idle →
|
|
654
|
+
// settleContinuable → settleRun)。但 Worker 通过 board_report 工具上报完成时,任务状态已被工具通道
|
|
655
|
+
// 推进到 verifying/resolved——此时若该 turn 的 idle 事件没被观测到(rec.ran 二次守卫挡下的伪 idle、
|
|
656
|
+
// 驱动未接上的冷窗口、宿主重启、idle 事件丢失),run 记录就永久停在 outcome='running':
|
|
657
|
+
// ① 软超时臂对它误报(实证:已完成的 run 收到「worker 已运行 30 分钟仍未完成」);
|
|
658
|
+
// ② usage 不落账(仪表盘漏计这轮消耗);
|
|
659
|
+
// ③ 硬超时臂到期还对一条早已结算的闲置会话补一枪 interrupt(噪音 + 误导)。
|
|
660
|
+
// 解法:board_report/board_verdict 把任务推进到落定态(verifying/resolved)时,顺带调本函数——
|
|
661
|
+
// 对**该任务当前的 continuable Worker rec**补做收尾三件套(关 run 结局 / usage 落账 / 摘超时臂),
|
|
662
|
+
// 与事件通道共用 settleRunRecord(同一实现,杜绝两条路各写一套口径)。
|
|
663
|
+
// 三点边界:
|
|
664
|
+
// · 只认 continuable 且 role==='worker' 的活跃 rec:verifier/hook 是一次性 run。它们的 run.result
|
|
665
|
+
// 落定后 settleRun 会照常收尾;手动终止路径**刻意**不关 run(保留 running = 不留续跑资格),此处不碰。
|
|
666
|
+
// · 摘除活跃表项:任务已落定,这张卡不该再占 Worker 并发位;否则 poolCycle 的 activeW 会把它算成
|
|
667
|
+
// 在跑的 Worker(幽灵占位)。子会话本身不销毁也不 interrupt——它与手动终止同源:失联的续跑资格
|
|
668
|
+
// 留在卡上,之后真要重派走 tryResumeWorker 的 sendMessage 冷复活。
|
|
669
|
+
// · 幂等:closeRunHistory 只关 outcome==='running' 的条目、accumulateRunUsage 认 usageRecorded 旗、
|
|
670
|
+
// rec.disarm 幂等——重复上报 / 上报后 idle 事件补到,都不会二次关账或二次记账。
|
|
671
|
+
// 老宿主/没接线的调用方:deps 里没有这个函数时 rpc 侧静默跳过,行为与本次改造前逐字一致。
|
|
672
|
+
async function settleReportedRun(sid, taskId, opts) {
|
|
673
|
+
var rec = runsFor(sid)[taskId]
|
|
674
|
+
if (!rec || !rec.continuable || rec.role !== 'worker') return false
|
|
675
|
+
if (runsFor(sid)[rec.taskId] !== rec) return false
|
|
676
|
+
delete runsFor(sid)[rec.taskId] // 与 settleRun 同口径:先摘活跃表项,堵住并发重入
|
|
677
|
+
rec.settled = true
|
|
678
|
+
await settleRunRecord(sid, rec, { outcome: (opts && opts.outcome) || 'completed' })
|
|
679
|
+
return true
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ===== host 事件订阅:agent/status(continuable Worker 的 turn 结算触发器,task-muw5gnhv 卡1)=====
|
|
683
|
+
// 事件契约(host Event 目录实证):'agent/status'(payload: { agent, status: 'idle'|'running' })。
|
|
684
|
+
// 「子代理 turn 完成」在事件面就是 running→idle;continuable 没有 run.result promise,故这是唯一可靠的
|
|
685
|
+
// 结算信号。订阅形态:ctx.on(本插件 host 侧首个事件订阅点),一律走 ctx.effect 回收——
|
|
686
|
+
// 插件卸载/HMR 重载时监听器必须随之注销,否则重载后同一事件会被多份旧闭包重复结算(重复结算虽被
|
|
687
|
+
// rec.settled / runsFor 认领双重挡住,但旧闭包里过期的 state 引用本身就是内存泄漏)。
|
|
688
|
+
// 二次守卫 rec.ran:只有**观测到过 running** 的 rec 才允许被 idle 结算。理由:startContinuable 返回后
|
|
689
|
+
// 子会话可能先报一个瞬时 idle(驱动尚未接上/冷复活窗口),若拿它结算,任务会以「空文本」被推进/回退。
|
|
690
|
+
// 真实 turn 必然先 running 后 idle,因此该守卫只挡伪 idle,不挡正常结算。
|
|
691
|
+
function onAgentStatus(payload) {
|
|
692
|
+
try {
|
|
693
|
+
// ⚠️ 不能在此只放行 idle:running 事件正是 rec.ran 的唯一来源(见下方守卫),
|
|
694
|
+
// 早退会让 rec.ran 永远 false → idle 永远不结算(任务只能等硬超时)——本实现踩过的坑。
|
|
695
|
+
var status = payload && payload.status
|
|
696
|
+
if (status !== 'idle' && status !== 'running') return
|
|
697
|
+
var agent = payload.agent
|
|
698
|
+
// 身份来源修正(2026-10-06 热修,活体实证):dsh-agent 的 Agent 接口**没有 id 字段**——
|
|
699
|
+
// 被驱动的会话才是身份(agent.session.id)。此前读 agent.id 恒 undefined → cid='' 早退,
|
|
700
|
+
// 事件结算通道从卡1上线起从未真正触发(所有结算全靠 run.result / board_report 通道顶着)。
|
|
701
|
+
// agent.id 作兜底保留(防未来内核补上该字段时形状分叉)。
|
|
702
|
+
var cid = agent ? String((agent.session && agent.session.id) || agent.id || '') : ''
|
|
703
|
+
if (!cid) return
|
|
704
|
+
var table = state.activeRuns
|
|
705
|
+
// 事件按会话 id 命中活跃 rec:continuable rec 的 childId 就是子会话 id(= agent.session.id),
|
|
706
|
+
// 故这里是 O(1) 的 rec.childId 比对,不做任何 agent 注册表扫描。
|
|
707
|
+
Object.keys(table).forEach(function (sid) {
|
|
708
|
+
var rr = table[sid]; if (!rr) return
|
|
709
|
+
Object.keys(rr).forEach(function (k) {
|
|
710
|
+
var rec = rr[k]
|
|
711
|
+
if (!rec || !rec.continuable || rec.childId !== cid) return
|
|
712
|
+
if (status === 'running') { rec.ran = true; return }
|
|
713
|
+
if (!rec.ran || rec.settled) return
|
|
714
|
+
rec.settled = true // 先立旗:堵住硬超时臂与重复事件的并发重入
|
|
715
|
+
settleContinuable(sid, rec).catch(function (e) { console.error('[task-board] settleContinuable failed (task ' + rec.taskId + '):', String(e)) })
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
} catch (e) { console.error('[task-board] agent/status 处理失败:', String(e)) }
|
|
719
|
+
}
|
|
720
|
+
// 注册(cordis 标准事件订阅):返回的 disposer 交给 ctx.effect 回收。
|
|
721
|
+
// 老宿主/测试桩上没有 ctx.on 时静默跳过——派发照常,只是没有事件结算通道(一次性路径本就不需要)。
|
|
722
|
+
if (typeof ctx.on === 'function') {
|
|
723
|
+
try {
|
|
724
|
+
ctx.effect(function () { return ctx.on('agent/status', onAgentStatus) })
|
|
725
|
+
} catch (e) { console.error('[task-board] agent/status 订阅失败:', String(e)) }
|
|
726
|
+
}
|
|
727
|
+
|
|
316
728
|
async function settleWorker(sid, rec, output, failed, errText) {
|
|
317
729
|
// 完成回执开关(设置区「通知」notifyDone):在同一次持锁回调里读看板文档(零额外读盘),
|
|
318
730
|
// 缺字段/回调未跑到时保持 true——宁可多报一条,也不因读配置失败漏报完成。
|
|
@@ -327,22 +739,22 @@ export function createDispatch(ctx, state, deps) {
|
|
|
327
739
|
// 失败原因落卡(截断 300):卡片详情页可直接查看最近失败原因,排查三连败不再靠猜
|
|
328
740
|
t.lastError = String(errText || '').slice(0, 300)
|
|
329
741
|
t.retryCount = (t.retryCount || 0) + 1
|
|
330
|
-
if (t.retryCount >= 3) { var ps = t.status; t.status = 'blocked'; ah(t, ps, 'blocked', String(rec.
|
|
331
|
-
var ps2 = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps2, 'pending', String(rec.
|
|
742
|
+
if (t.retryCount >= 3) { var ps = t.status; t.status = 'blocked'; ah(t, ps, 'blocked', String(rec.id), 'worker 失败 x' + t.retryCount + '(' + String(errText).slice(0, 120) + '),待人工介入'); return { task: t, blocked: true } }
|
|
743
|
+
var ps2 = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps2, 'pending', String(rec.id), 'worker 失败(' + String(errText).slice(0, 80) + '),重新排队 (' + t.retryCount + '/3)')
|
|
332
744
|
return { task: t, retry: true }
|
|
333
745
|
}
|
|
334
746
|
if (/\[ESCALATE\]/i.test(output || '')) {
|
|
335
|
-
t.escalation = { question: output.slice(0, 2000), at: new Date().toISOString(), by: String(rec.
|
|
747
|
+
t.escalation = { question: output.slice(0, 2000), at: new Date().toISOString(), by: String(rec.id) }
|
|
336
748
|
if (!Array.isArray(t.messages)) t.messages = []
|
|
337
|
-
t.messages.push({ kind: 'escalation', text: output.slice(0, 4000), at: t.escalation.at, by: String(rec.
|
|
338
|
-
ah(t, 'in-progress', 'in-progress', String(rec.
|
|
749
|
+
t.messages.push({ kind: 'escalation', text: output.slice(0, 4000), at: t.escalation.at, by: String(rec.id) })
|
|
750
|
+
ah(t, 'in-progress', 'in-progress', String(rec.id), 'worker 上报歧义(文本通道),待主窗口裁决')
|
|
339
751
|
return { task: t, escalated: true }
|
|
340
752
|
}
|
|
341
753
|
// 文本降级路径:分段格式上报
|
|
342
754
|
var secs = parseSections(output)
|
|
343
755
|
delete t.retryCount; delete t.stuckSince; delete t.lastError // 成功路径:失败计数/卡死标记/最近失败原因一并清除
|
|
344
|
-
t.deliverable = { summary: secs.summary || output.slice(0, 600), changes: secs.changes || '', selfTest: secs.selfTest || '', diff: (secs.diff || '').slice(0, 4000), at: new Date().toISOString(), by: String(rec.
|
|
345
|
-
resolveApply(d, t, String(rec.
|
|
756
|
+
t.deliverable = { summary: secs.summary || output.slice(0, 600), changes: secs.changes || '', selfTest: secs.selfTest || '', diff: (secs.diff || '').slice(0, 4000), at: new Date().toISOString(), by: String(rec.id) }
|
|
757
|
+
resolveApply(d, t, String(rec.id), 'verifying', output || 'Worker 完成', 'worker 文本上报完成')
|
|
346
758
|
return { task: t }
|
|
347
759
|
})
|
|
348
760
|
if (!result) return
|
|
@@ -378,13 +790,13 @@ export function createDispatch(ctx, state, deps) {
|
|
|
378
790
|
var approved = vm[1].toUpperCase() === 'APPROVED'
|
|
379
791
|
var vsecs = parseSections(trimmed)
|
|
380
792
|
delete t.stuckSince; delete t.verifyRetries; delete t.lastError // 成功给出结论:卡死标记/重试计数/最近失败原因一并清除
|
|
381
|
-
t.verification = { verdict: approved ? 'approved' : 'rejected', summary: vsecs.verifySummary || trimmed.slice(0, 600), checks: vsecs.checks || '', at: new Date().toISOString(), by: String(rec.
|
|
382
|
-
verifyApply(d, t, String(rec.
|
|
793
|
+
t.verification = { verdict: approved ? 'approved' : 'rejected', summary: vsecs.verifySummary || trimmed.slice(0, 600), checks: vsecs.checks || '', at: new Date().toISOString(), by: String(rec.id) }
|
|
794
|
+
verifyApply(d, t, String(rec.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
|
|
383
795
|
if (!approved) {
|
|
384
796
|
t.rejectCount = (t.rejectCount || 0) + 1
|
|
385
797
|
// 驳回包全量带回(task-muvg15p5):summary + checks(逐条核对证据)落 messages,
|
|
386
798
|
// 随 buildMessages 注入重派 Worker prompt——history 里只有 200 字截断,Worker 据此无法返工
|
|
387
|
-
pushRejection(t, vsecs.verifySummary, vsecs.checks, t.verification.at, String(rec.
|
|
799
|
+
pushRejection(t, vsecs.verifySummary, vsecs.checks, t.verification.at, String(rec.id))
|
|
388
800
|
// 学习飞轮 v1:Verifier 驳回 → 候选教训(场景/错误做法/来源),随 t.verification.at 判重
|
|
389
801
|
pushRejectLesson(d, t, vsecs.verifySummary || trimmed, t.verification.at)
|
|
390
802
|
if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') }
|
|
@@ -412,7 +824,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
412
824
|
async function settleHook(sid, rec, output, failed, errText) {
|
|
413
825
|
var phase = rec.role === 'hook-pre' ? 'pre' : 'post'
|
|
414
826
|
var esc = /\[ESCALATE\]/i.test(output || '')
|
|
415
|
-
var runId = String(rec.
|
|
827
|
+
var runId = String(rec.id)
|
|
416
828
|
// 完成回执开关(notifyDone):hook 收口把 epic 推进到 blocked/verifying 同样算完成回执,一视同仁
|
|
417
829
|
var doneOn = true
|
|
418
830
|
var result = await mutateLocked(sid, function (d) {
|
|
@@ -428,7 +840,192 @@ export function createDispatch(ctx, state, deps) {
|
|
|
428
840
|
kickCycle(sid) // 结算后立刻补派(pre 完成 → 子任务开跑;post 完成 → epic 进验收)
|
|
429
841
|
}
|
|
430
842
|
|
|
843
|
+
// ===== 重启 reconcile(卡3 Step2):host 重启后把活跃 continuable Worker 的观测重新挂回来 =====
|
|
844
|
+
// 问题:continuable Worker 的 rec 只活在内存(state.activeRuns)——host 重启把它清零,而子会话
|
|
845
|
+
// 本身是**持久子会话**,可能正跑得好好(或刚跑完一轮)。此时任务停在 in-progress 却没人认领:
|
|
846
|
+
// 既不会被 agent/status 结算(表里没 rec),又不会被孤儿回收(isOrphan 的 2 分钟门槛+它确实
|
|
847
|
+
// 有 claimedBy),只能干等——重启一次就白挂一个 Worker 位。
|
|
848
|
+
// 解法(一次,per host 生命周期):对本会话板上「in-progress 且无活跃 rec」的任务查 listChildren(root),
|
|
849
|
+
// · 该 childId 仍在列 → 判定存活:重建 rec 观测(agent/status 监听本来就在,登记即可续上),
|
|
850
|
+
// 基线取**当前日志字节数**(重启前那轮的产出已落盘,不该冒充重启后的新交付物),重挂两级超时臂;
|
|
851
|
+
// · 不在列 → 会话已死:走「硬超时等价物」——失败结算的等价动作(回 pending 重排 + 留历史),
|
|
852
|
+
// 不占用 Worker 并发位,等下一轮 poolCycle 正常重派(续跑资格由卡上 runs 留档决定)。
|
|
853
|
+
// 三道门禁:①只对 role=worker + continuable + 结局停在 running 的 run(一次性 run 没有可找回的会话);
|
|
854
|
+
// ②per host 生命周期只做一次(state.reconcileDone)——重复做会把用户手动终止的卡又挂回去;
|
|
855
|
+
// ③listChildren 不可用(老宿主/测试桩)→ 整段跳过,行为逐字不变。
|
|
856
|
+
// 已知边界(有意不做,交给既有机制):重启**之后**才被中断的那一轮,reconcile 追不回它的结局——
|
|
857
|
+
// 硬超时臂已在重建时重挂,到点照常结算。
|
|
858
|
+
async function reconcileRoot(sid) {
|
|
859
|
+
if (!state.reconcileDone) state.reconcileDone = {} // 老宿主/测试桩没建这个容器:就地补(不清空已有标记)
|
|
860
|
+
if (state.reconcileDone[sid]) return
|
|
861
|
+
state.reconcileDone[sid] = true
|
|
862
|
+
var subagents = ctx.subagents
|
|
863
|
+
if (!subagents || typeof subagents.listChildren !== 'function') return
|
|
864
|
+
var runs = runsFor(sid)
|
|
865
|
+
var snap = null
|
|
866
|
+
try { snap = await rt(sid) } catch (_) { return }
|
|
867
|
+
// 候选:in-progress + 在跑占位已换成真实 run id + 无活跃 rec + 最后一条 worker run 是 continuable
|
|
868
|
+
// 且结局仍停在 running(已落 timeout/error 的卡由重派路径自己处理,不在此处抢跑)。
|
|
869
|
+
var cands = []
|
|
870
|
+
var list = (snap && snap.tasks) || []
|
|
871
|
+
for (var i = 0; i < list.length; i++) {
|
|
872
|
+
var t = list[i]
|
|
873
|
+
if (!t || t.status !== 'in-progress' || t.escalation) continue
|
|
874
|
+
if (runs[t.id]) continue
|
|
875
|
+
if (!t.claimedBy || t.claimedBy === 'spawn-pending') continue
|
|
876
|
+
if (resumeTarget(t) !== '') continue // 结局已落超时/失败 → 归重派续跑路径,reconcile 不掺和
|
|
877
|
+
var last = null
|
|
878
|
+
var trs = Array.isArray(t.runs) ? t.runs : []
|
|
879
|
+
for (var ri = trs.length - 1; ri >= 0; ri--) { if (trs[ri] && trs[ri].role === 'worker') { last = trs[ri]; break } }
|
|
880
|
+
if (!last || last.continuable !== true || last.outcome !== 'running') continue
|
|
881
|
+
if (String(last.id) !== String(t.claimedBy)) continue // 卡上留档与占位不一致:状态可疑,不动
|
|
882
|
+
cands.push(t)
|
|
883
|
+
}
|
|
884
|
+
if (!cands.length) return
|
|
885
|
+
// 存活判定:listChildren 列出的就是「活的子会话」。带超时保护(查询挂死也不能拖住心跳)。
|
|
886
|
+
var kids = null
|
|
887
|
+
try {
|
|
888
|
+
var q = subagents.listChildren(sid)
|
|
889
|
+
kids = (typeof withTimeout === 'function') ? await withTimeout(q, 5000, 'reconcile listChildren') : await q
|
|
890
|
+
} catch (e) {
|
|
891
|
+
console.error('[task-board] reconcile listChildren 失败(本轮跳过,卡片留在 in-progress 等人工处理):', String(e))
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
var alive = {}
|
|
895
|
+
var kidList = Array.isArray(kids) ? kids : []
|
|
896
|
+
for (var k = 0; k < kidList.length; k++) {
|
|
897
|
+
var c = kidList[k]; if (!c) continue
|
|
898
|
+
var cid = String(c.sessionId || c.id || '')
|
|
899
|
+
if (cid) alive[cid] = true
|
|
900
|
+
}
|
|
901
|
+
var c2 = cfg(snap)
|
|
902
|
+
var found = 0, lost = 0
|
|
903
|
+
for (var ci = 0; ci < cands.length; ci++) {
|
|
904
|
+
var tc = cands[ci]
|
|
905
|
+
var childId = String(tc.claimedBy)
|
|
906
|
+
if (alive[childId]) {
|
|
907
|
+
// ① 存活:重建 rec(与 spawnOneShot 的 continuable 形态逐字同构)→ 重挂两级超时臂。
|
|
908
|
+
// ran:false —— 重启前的 running 事件已经错过,等下一个真 running 事件到来才允许 idle 结算
|
|
909
|
+
// (rec.ran 二次守卫的既有语义:宁可等,也不拿一个可能已在跑的会话的空文本当交付物)。
|
|
910
|
+
var rec = { id: childId, childId: childId, continuable: true, restored: true, ran: false, run: null, role: 'worker', taskId: tc.id, startedAt: Date.now(), model: '', settled: false, baselineBytes: logSizeOf(childId) }
|
|
911
|
+
runs[tc.id] = rec
|
|
912
|
+
if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
|
|
913
|
+
dispatchedEver[sid][childId] = true
|
|
914
|
+
armTimeouts(sid, rec, tc, c2)
|
|
915
|
+
found++
|
|
916
|
+
// 系统消息只留一行(重启是低频事件,且这行是「为什么它还在跑」的唯一解释来源)
|
|
917
|
+
pushSysNote(sid, '任务「' + tc.title + '」的 Worker 子会话已在重启后找回(续跑观测已恢复,硬超时 ' + c2.hardTimeoutMin + ' 分钟后照常结算)', tc.id)
|
|
918
|
+
} else lost++
|
|
919
|
+
}
|
|
920
|
+
if (found || lost) console.log('[task-board] 重启 reconcile(' + sid + '):找回活跃续跑 Worker ' + found + ' 个,' + lost + ' 个子会话已死转回 pending')
|
|
921
|
+
// ② 会话已死:硬超时等价物(失败结算的等价动作)——回 pending 重排 + 留一行 history + 落结局。
|
|
922
|
+
// 放在**锁外**统一做(一次持锁批量处理,避免逐张写盘打断派发周期)。
|
|
923
|
+
if (lost) {
|
|
924
|
+
try {
|
|
925
|
+
await mutateLocked(sid, function (d) {
|
|
926
|
+
d.tasks.forEach(function (t) {
|
|
927
|
+
if (t.status !== 'in-progress' || runs[t.id] || !t.claimedBy || t.claimedBy === 'spawn-pending') return
|
|
928
|
+
var last = null
|
|
929
|
+
var trs = Array.isArray(t.runs) ? t.runs : []
|
|
930
|
+
for (var ri = trs.length - 1; ri >= 0; ri--) { if (trs[ri] && trs[ri].role === 'worker') { last = trs[ri]; break } }
|
|
931
|
+
if (!last || last.continuable !== true || last.outcome !== 'running') return
|
|
932
|
+
if (String(last.id) !== String(t.claimedBy) || alive[String(t.claimedBy)]) return
|
|
933
|
+
var ps = t.status
|
|
934
|
+
t.status = 'pending'; t.claimedBy = null; t.claimedAt = null
|
|
935
|
+
t.lastError = 'host 重启后 Worker 子会话已不存在(reconcile 未找回),任务回收重排'
|
|
936
|
+
last.outcome = 'timeout/error'; last.endedAt = new Date().toISOString()
|
|
937
|
+
ah(t, ps, 'pending', 'system', '重启后执行会话已丢失,回收重新排队')
|
|
938
|
+
return
|
|
939
|
+
})
|
|
940
|
+
return { ok: true }
|
|
941
|
+
}, true)
|
|
942
|
+
} catch (e) { console.error('[task-board] reconcile 死会话回收失败:', String(e)) }
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ===== 幽灵占位回收(池冻结根修,task-muwlepg6)=====
|
|
947
|
+
// 病根(活体实证):活跃表项(rec)的**唯一**摘除点是各结算通道(事件通道 settleRun /
|
|
948
|
+
// 上报通道 settleReportedRun / 手动终止)。任何一条漏走(worker 经 board_report 完成但当时
|
|
949
|
+
// 还没有上报通道接线、伪 idle 被 rec.ran 守卫挡下、冷窗口、插件热重载丢回调)都会让 rec
|
|
950
|
+
// 永久留在 state.activeRuns 里,而它同时是四个判定的输入:
|
|
951
|
+
// ① activeW → capW = max(0, maxWorkers - activeW):残留数 ≥ maxWorkers 时 capW 恒 0 →
|
|
952
|
+
// **整池 Worker 自动派发全停**(实测板:5 个残留 + 1 个真在跑 → activeW=6 > maxWorkers=3 → capW=0,
|
|
953
|
+
// 9 张 pending 卡一张都派不出去);
|
|
954
|
+
// ② pickDispatch 的 busyTaskIds(= 本表)→ 该卡自己永远进不了 Verifier 派发(verifying 卡永不 spawn);
|
|
955
|
+
// ③ isOrphan 的 !runs[t.id] → 该卡永不被孤儿回收(in-progress 死卡持着 touches 锁堵住后续卡);
|
|
956
|
+
// ④ poolStatus 快照 → UI 上「幽灵 Worker 卡 busy」。
|
|
957
|
+
// 四者叠加 = 「poolCycle 整体冻结,force dispatch-task 手动通道却正常」——手动通道
|
|
958
|
+
// (rpc dispatch-task → spawnOneShot)不经 capW / busyTaskIds 闸门,故症状完全吻合。
|
|
959
|
+
// 修法:不动任何结算语义,只做**表项 GC**——每轮 cycle 在算活跃度之前,用「卡面证据」核对每个 rec:
|
|
960
|
+
// 卡已不存在、或卡的状态已不可能是该 rec 的在跑态(worker 不在 in-progress / verifier 不在 verifying /
|
|
961
|
+
// hook 状态不是 running)、或卡的认领位已换成别的 run id → 该 rec 已失效,立刻摘除。
|
|
962
|
+
// 摘除是**同步**的(先删表项,本轮 activeW/busyTaskIds 立刻干净、派发当轮恢复),随后用与两条
|
|
963
|
+
// 结算通道**同一个** settleRunRecord 补一次收尾(结局 / usage / 摘超时臂,三者各自幂等)——
|
|
964
|
+
// 不另立第二套口径,也不重复记账(usageRecorded 旗与「只关还开着的条目」双保险)。
|
|
965
|
+
// 边界(宁少勿多,绝不动真在跑的表项):
|
|
966
|
+
// · 卡面读不到(__noPersist 空板 / tasks 非数组)→ 整段跳过:瞬时读盘失败不能把真 Worker 当幽灵杀;
|
|
967
|
+
// · 只认卡面证据,**不认** rec.settled——settled=true 的表项可能正被结算通道认领中
|
|
968
|
+
// (onAgentStatus 先立旗、同一次同步块里才摘表项),此刻抢先摘除会让 settleRun 的认领守卫
|
|
969
|
+
// 早退、把这一轮的交付物整份丢掉;
|
|
970
|
+
// · 单条判定/收尾各自 try/catch:一条脏数据只影响该条,不拖死本轮其它回收与派发。
|
|
971
|
+
function recIsLive(t, rec) {
|
|
972
|
+
if (!t || !rec) return false // 卡已不存在(板被重建/换工作区)→ 表项无主
|
|
973
|
+
if (rec.role === 'worker') {
|
|
974
|
+
// Worker 只在 in-progress 活着;claimedBy 为空 = 占位没落座/已被清(僵尸态)
|
|
975
|
+
if (t.status !== 'in-progress' || !t.claimedBy) return false
|
|
976
|
+
if (t.claimedBy !== 'spawn-pending' && String(t.claimedBy) !== String(rec.id)) return false // 认领位已换人(terminate 后重派)
|
|
977
|
+
return true
|
|
978
|
+
}
|
|
979
|
+
if (rec.role === 'verifier') {
|
|
980
|
+
if (t.status !== 'verifying') return false
|
|
981
|
+
if (t.verifierRun && t.verifierRun !== 'spawn-pending' && String(t.verifierRun) !== String(rec.id)) return false
|
|
982
|
+
return true
|
|
983
|
+
}
|
|
984
|
+
if (rec.role === 'hook-pre') return t.status === 'in-progress' && hookOn(t, 'pre') && t.hooks.pre.state === 'running'
|
|
985
|
+
if (rec.role === 'hook-post') return t.status === 'in-progress' && hookOn(t, 'post') && t.hooks.post.state === 'running'
|
|
986
|
+
// 未知角色(前向兼容):只要卡还在跑态就留着,等卡落定后由这里回收——不在未知形态上做激进判定
|
|
987
|
+
return t.status === 'in-progress' || t.status === 'verifying'
|
|
988
|
+
}
|
|
989
|
+
// 返回本轮摘除条数(调用方据此决定要不要重读盘——收尾写盘会让本轮快照过期)
|
|
990
|
+
async function reapGhostRecs(sid, snap) {
|
|
991
|
+
if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0
|
|
992
|
+
var runs = runsFor(sid)
|
|
993
|
+
var tasks = snap.tasks
|
|
994
|
+
var reaped = []
|
|
995
|
+
Object.keys(runs).forEach(function (k) {
|
|
996
|
+
var rec = runs[k]
|
|
997
|
+
if (!rec) return
|
|
998
|
+
try {
|
|
999
|
+
var t = null
|
|
1000
|
+
for (var i = 0; i < tasks.length; i++) { if (tasks[i] && tasks[i].id === rec.taskId) { t = tasks[i]; break } }
|
|
1001
|
+
if (recIsLive(t, rec)) return
|
|
1002
|
+
delete runs[k] // 先摘表项:本轮 activeW / busyTaskIds / poolStatus 立刻不再被它污染
|
|
1003
|
+
try { if (typeof rec.disarm === 'function') rec.disarm() } catch (_) {} // 摘两级超时臂(幂等)
|
|
1004
|
+
rec.settled = true // 堵住迟到的结算通道(settleRun 的认领守卫会早退,不会二次推进任务)
|
|
1005
|
+
// 结局口径:卡已落定(resolved/archived/cancelled)或已交验收(verifying)→ 这轮 Worker 交付
|
|
1006
|
+
// 确实完成了;卡还在跑态但认领位换了人/卡丢了 → 结局不可知,记 incomplete(与一次性路径的
|
|
1007
|
+
// 「无 err 的失败」同口径,且据此保留续跑资格由重派路径判断)。
|
|
1008
|
+
var closed = t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled' || t.status === 'verifying'
|
|
1009
|
+
reaped.push({ rec: rec, outcome: closed ? 'completed' : 'incomplete' })
|
|
1010
|
+
} catch (e) { console.error('[task-board] 幽灵表项判定失败(本轮跳过该条):', String(e)) }
|
|
1011
|
+
})
|
|
1012
|
+
if (!reaped.length) return 0
|
|
1013
|
+
for (var r = 0; r < reaped.length; r++) {
|
|
1014
|
+
try { await settleRunRecord(sid, reaped[r].rec, { outcome: reaped[r].outcome }) }
|
|
1015
|
+
catch (e) { console.error('[task-board] 幽灵表项收尾失败 (task ' + reaped[r].rec.taskId + '):', String(e)) }
|
|
1016
|
+
}
|
|
1017
|
+
console.log('[task-board] 回收失效活跃表项(幽灵占位)' + reaped.length + ' 个:' + reaped.map(function (x) { return String(x.rec.taskId) + '/' + String(x.rec.role) + '/' + x.outcome }).join(', '))
|
|
1018
|
+
return reaped.length
|
|
1019
|
+
}
|
|
1020
|
+
|
|
431
1021
|
// ===== 派发周期(15s 心跳 + 写入后 kickCycle 触发)=====
|
|
1022
|
+
// 结构(池冻结防御,task-muwlepg6):入口 root 闸门 → 读盘 → 本轮主体(poolCycleBody)整段包 try。
|
|
1023
|
+
// 为什么必须拆这一层:主体里任一环(reconcile / 幽灵回收 / 派发决策 / spawn / 回执)抛异常,
|
|
1024
|
+
// 冒泡出去就是「本轮整池零动作」;再叠上任何 latch 型守卫就是永久冻结(本次事故的形状)。
|
|
1025
|
+
// 这里把「整轮异常」隔离成「本轮作废、下一轮照常」。
|
|
1026
|
+
// 刻意**不设**再入守卫:latch 一旦漏复位就是全停且难观测;重复 cycle 由 mutateLocked 的
|
|
1027
|
+
// 占位 claim 原子性兜住,无需互斥。全仓唯一的 latch 是 store.kickCycle 的 cyclePending 去抖,
|
|
1028
|
+
// 那边用「时间戳 + 兜底复位」保证不可能恒真(见 store.mjs)。
|
|
432
1029
|
async function poolCycle(sid) {
|
|
433
1030
|
// root 存活早闸门(根治 no-root 刷屏,task-muuf0o7a):无活 root 的会话板根本不进派发循环——
|
|
434
1031
|
// 不读盘、不 claim、不回收、不写盘、零日志。幻影板(裸短 id 建的 tasks-<短id>.json,真实会话
|
|
@@ -441,11 +1038,27 @@ export function createDispatch(ctx, state, deps) {
|
|
|
441
1038
|
var info = []
|
|
442
1039
|
var runs = runsFor(sid)
|
|
443
1040
|
var snap = await rt(sid)
|
|
1041
|
+
// 本轮主体整段进 try:任一环节抛异常都只作废本轮(console 留痕),下一轮心跳 / kickCycle 照常进来。
|
|
1042
|
+
try { return await poolCycleBody(sid, info, runs, snap) }
|
|
1043
|
+
catch (e) { console.error('[task-board] poolCycle 本轮异常(已隔离,下一轮照常)([' + sid + ']):', String(e)); return undefined }
|
|
1044
|
+
}
|
|
1045
|
+
// 本轮主体:reconcile → 幽灵占位回收 → 活跃度/上限闸门 → 持锁(回收+claim+池快照)→ 锁外 spawn。
|
|
1046
|
+
async function poolCycleBody(sid, info, runs, snap) {
|
|
1047
|
+
// 重启 reconcile(卡3 Step2):只在本 host 生命周期的首轮 cycle 做一次(内部有 reconcileDone 标记)。
|
|
1048
|
+
// 位置取舍:root 闸门必须是第一件事(无活 root 的板零 IO),所以本段只能排在早退闸门与 rt 读盘之后;
|
|
1049
|
+
// 而重建出来的 rec 仍在本轮被读到(活跃计数 / 池状态快照都在下面才求值),语义不受影响。
|
|
1050
|
+
try { await reconcileRoot(sid) } catch (e) { console.error('[task-board] reconcile 失败:', String(e)) }
|
|
1051
|
+
// 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
|
|
1052
|
+
// 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
|
|
1053
|
+
// 故有回收时重读一次快照——否则下面空闲快进的 wt(snap) 会拿旧快照把收尾结果覆盖回去。
|
|
1054
|
+
var reapedN = 0
|
|
1055
|
+
try { reapedN = await reapGhostRecs(sid, snap) } catch (e) { console.error('[task-board] 幽灵占位回收失败(本轮跳过,派发照常):', String(e)) }
|
|
1056
|
+
if (reapedN > 0) { try { snap = await rt(sid) } catch (_) {} }
|
|
444
1057
|
var activeW = 0, activeV = 0
|
|
445
1058
|
// 角色口径:worker 计入 activeW(占 Worker 并发位);verifier 与 hook run(hook-pre/hook-post)
|
|
446
1059
|
// 统一计入 activeV——hook run 不是 Worker,不该挤占 maxWorkers 并发位,但它确实是一条在跑的 run,
|
|
447
1060
|
// 必须参与「空闲快进」判定,否则 hook 跑着时重复派发的闸门会失守。
|
|
448
|
-
Object.keys(runs).forEach(function (k) {
|
|
1061
|
+
Object.keys(runs).forEach(function (k) { var rc0 = runs[k]; if (!rc0) return; if (rc0.role === 'worker') activeW++; else activeV++ })
|
|
449
1062
|
// 空闲快进:无活跃任务且无活跃 run → 不写盘直接返回(心跳每 15s 跑一次,不能每次都写文件)
|
|
450
1063
|
var hasActive = snap.tasks.some(function (t) { return t.status === 'pending' || t.status === 'verifying' || t.status === 'in-progress' })
|
|
451
1064
|
if (!hasActive && activeW + activeV === 0) {
|
|
@@ -464,97 +1077,201 @@ export function createDispatch(ctx, state, deps) {
|
|
|
464
1077
|
|
|
465
1078
|
// 持锁:孤儿回收 + 占位 claim(防并发 cycle 重复派发)+ 池状态快照,一次原子写
|
|
466
1079
|
// 孤儿回收 + verifier 派发在两种模式都跑;worker 派发仅 auto 模式(manual 模式主窗口自己做)
|
|
1080
|
+
// ===== 单点异常隔离(池冻结防御,task-muwlepg6)=====
|
|
1081
|
+
// 持锁段内按阶段各自 try/catch:回收/派发决策/hooks/touches 展示/池快照五段互不牵连,
|
|
1082
|
+
// 任一阶段或任一卡抛异常都只损失那一小段(console 留痕),回调**照样返回 d** ——
|
|
1083
|
+
// 保证本轮该写的池状态一定写下去(旧形状下一处异常=整轮回调抛穿=本轮什么都不写、
|
|
1084
|
+
// 连孤儿回收与池快照都白跑,且这个形状每轮重复时看起来就是「池被冻住」)。
|
|
467
1085
|
var toSpawn = []
|
|
468
1086
|
var result = await mutateLocked(sid, function (d) {
|
|
469
1087
|
var now = Date.now()
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1088
|
+
// ① 孤儿回收 + 占位超时回收(逐卡兜底:单卡脏数据不中断整轮)
|
|
1089
|
+
try {
|
|
1090
|
+
d.tasks.forEach(function (t) {
|
|
1091
|
+
try {
|
|
1092
|
+
if (isOrphan(d, t, runs, now)) { t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, 'in-progress', 'pending', 'system', '执行 run 已结束/丢失,回收重新排队'); pushSysNote(sid, '任务「' + t.title + '」执行 run 丢失,已回收重新排队'); info.push('reclaim ' + t.id) }
|
|
1093
|
+
// 派发占位超时回收(防死占位,task-muwlepg6 扩面):占位后强杀 / spawn 中断 / 插件热重载
|
|
1094
|
+
// 会留下 'spawn-pending' 死占位;超 2min 一律清掉恢复可派发。覆盖面从「verifying 卡的
|
|
1095
|
+
// Verifier」扩到**任何卡上残留的 spawn-pending**——in-progress 史诗复用这一位做
|
|
1096
|
+
// hook-post 的幂等占用(原实现漏了这一半,hook-post 死占位会让史诗永久不 spawn 收口)。
|
|
1097
|
+
if (t.verifierRun === 'spawn-pending' && (now - new Date(t.verifierRunAt || 0).getTime()) > 120000) {
|
|
1098
|
+
t.verifierRun = null; delete t.verifierRunAt
|
|
1099
|
+
if (t.status === 'verifying') ah(t, 'verifying', 'verifying', 'system', 'Verifier 派发占位超时,回收重新排队')
|
|
1100
|
+
// hook-post 的幂等占用位同款清理:待跑标记置回,下一轮重新 spawn 收口
|
|
1101
|
+
if (t.status === 'in-progress' && t.hooks && t.hooks.post && t.hooks.post.state !== 'done') t.hooks.post.pending = true
|
|
1102
|
+
info.push('reclaim-verifier ' + t.id)
|
|
1103
|
+
}
|
|
1104
|
+
// hook-pre 死占位回收:state='running' 但活跃表里没有它的 rec(spawn 途中进程被杀/热重载
|
|
1105
|
+
// 留下的半成品)→ 超 2min 退回 idle + 待跑标记,串行闸门保持关闭待重试。判据用「无 rec」:
|
|
1106
|
+
// 真在跑的 hook-pre 一定在 runs 表里有 rec;首次见到这种残留(老数据无时间戳)只记时,
|
|
1107
|
+
// 下轮之后再判定——绝不在第一眼就把可能真在跑的 hook 拉回 idle(那会放子任务越过闸门)。
|
|
1108
|
+
if (t.hooks && t.hooks.pre && t.hooks.pre.state === 'running' && !runs[t.id]) {
|
|
1109
|
+
var preAt = new Date(t.hooks.pre.runAt || 0).getTime()
|
|
1110
|
+
if (!preAt) t.hooks.pre.runAt = new Date().toISOString()
|
|
1111
|
+
else if (now - preAt > 120000) { t.hooks.pre.state = 'idle'; t.hooks.pre.runId = null; t.hooks.pre.pending = true; info.push('reclaim-hook-pre ' + t.id) }
|
|
1112
|
+
}
|
|
1113
|
+
} catch (e) { console.error('[task-board] 回收判定失败 (task ' + (t && t.id) + '):', String(e)) }
|
|
1114
|
+
})
|
|
1115
|
+
} catch (e) { console.error('[task-board] 回收段异常(本轮跳过回收,派发照常):', String(e)) }
|
|
1116
|
+
// ② 派发决策 + 占位 claim(worker 仅 auto;verifier 两种模式都跑)
|
|
1117
|
+
try {
|
|
1118
|
+
var capW = isAuto ? Math.max(0, c.maxWorkers - activeW) : 0
|
|
1119
|
+
var picked = pickDispatch(d, capW, Math.max(0, c.maxVerifiers - activeV), runs)
|
|
1120
|
+
picked.pendings.forEach(function (t) { claimApply(d, t, 'spawn-pending', 'dispatch'); if (parentKickOnDispatch(d, t)) info.push('epic-kick ' + t.parentId); toSpawn.push({ role: 'worker', t: t }); info.push('dispatch ' + t.id) })
|
|
1121
|
+
picked.verifs.forEach(function (t) { t.verifierRun = 'spawn-pending'; t.verifierRunAt = new Date().toISOString(); toSpawn.push({ role: 'verifier', t: t }); info.push('verify ' + t.id) })
|
|
1122
|
+
} catch (e) { console.error('[task-board] 派发决策异常(本轮跳过派发,池状态照常写):', String(e)) }
|
|
1123
|
+
// ③ hooks=agent run:pre 闸门占用 + post 收口补 spawn(接线①③)
|
|
481
1124
|
// pre:候选被 pickDispatch 的 preHookGate 拦下时,本轮不派子任务——这里改 spawn hook-pre run。
|
|
482
1125
|
// 触发条件用状态机本身(state='idle' 即「已声明且从未跑过」,重启后从卡上原样恢复,不靠内存标记);
|
|
483
1126
|
// state='running' 说明已有 hook run 在跑(幂等,同一 epic 同时最多一条),'done'/'failed' 都轮不到这里。
|
|
484
1127
|
// post:子任务全部了结时 core.maybeAutoCloseParent 不直接转 verifying,只置 hooks.post.pending
|
|
485
1128
|
// 标记;本轮在这里看到标记就 spawn hook-post run(无活跃子任务时也能被下一次心跳收走)。
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
1129
|
+
try {
|
|
1130
|
+
d.tasks.forEach(function (t) {
|
|
1131
|
+
try {
|
|
1132
|
+
if (hookOn(t, 'pre') && t.hooks.pre.state === 'idle' && !runs[t.id]) {
|
|
1133
|
+
hookSetState(t, 'pre', 'running', 'system', '派发前置 hook run')
|
|
1134
|
+
t.hooks.pre.runAt = new Date().toISOString() // 死占位回收的时间基准(见①)
|
|
1135
|
+
toSpawn.push({ role: 'hook-pre', t: t })
|
|
1136
|
+
info.push('hook-pre ' + t.id)
|
|
1137
|
+
}
|
|
1138
|
+
if (t.status === 'in-progress' && hookOn(t, 'post') && t.hooks.post.pending && !t.verifierRun && !runs[t.id]) {
|
|
1139
|
+
t.hooks.post.pending = false
|
|
1140
|
+
t.hooks.post.state = 'running'
|
|
1141
|
+
t.verifierRun = 'spawn-pending' // 复用 Verifier 幂等占用位:只在 spawn 成功后换成真实 run id
|
|
1142
|
+
t.verifierRunAt = new Date().toISOString()
|
|
1143
|
+
toSpawn.push({ role: 'hook-post', t: t })
|
|
1144
|
+
info.push('hook-post ' + t.id)
|
|
1145
|
+
}
|
|
1146
|
+
} catch (e) { console.error('[task-board] hook 闸门判定失败 (task ' + (t && t.id) + '):', String(e)) }
|
|
1147
|
+
})
|
|
1148
|
+
} catch (e) { console.error('[task-board] hook 段异常(本轮跳过 hook spawn):', String(e)) }
|
|
1149
|
+
// ④ touches 文件级排他展示态:被拦候选写 t.waitingForTouches = [持有者任务id...],
|
|
502
1150
|
// 未被拦/已派发/已落定的任务清除该字段(每心跳刷新的 UI 展示态,不参与任何派发逻辑,
|
|
503
1151
|
// 但必须显式清——只在写入时报字段会留下"锁已释放仍显示 🔒 等待"的永久误导)。
|
|
504
|
-
// 持锁口径的唯一出处是 core.holdsFiles(in-progress + claimedBy / verifying
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1152
|
+
// 持锁口径的唯一出处是 core.holdsFiles(in-progress + claimedBy / verifying 持锁;
|
|
1153
|
+
// resolved/cancelled/archived 即放——锁只护「正在写」的阶段,用户 2026-10-06 裁决,
|
|
1154
|
+
// 回调 task-muv7c8ja 的「锁持到归档」口径)。这里不做任何显式放锁动作,也不需要:
|
|
1155
|
+
// 锁是随任务状态现算的派生量(不存在遗留的放锁调用点),resolveApply / verifyApply
|
|
1156
|
+
// 把卡落到 resolved 后,下一轮 tickInProgress 15s 轮自然把等待卡放行,并顺手清掉
|
|
1157
|
+
// waitingForTouches(下面这段每轮显式清,不会留下"锁已释放仍显示 🔒 等待")。
|
|
1158
|
+
// 「验收后-提交前」窗口期由主窗口提交纪律 + 史诗 post-hook 承接(见 core.holdsFiles 注释)。
|
|
1159
|
+
try {
|
|
1160
|
+
var waitMap = {}
|
|
1161
|
+
if (picked) picked.blockedTouches.forEach(function (b) { waitMap[b.id] = b.conflicts })
|
|
1162
|
+
d.tasks.forEach(function (t) {
|
|
1163
|
+
if (waitMap[t.id]) { t.waitingForTouches = waitMap[t.id]; info.push('wait-touches ' + t.id + '<-' + waitMap[t.id].join(',')) }
|
|
1164
|
+
else if (t.waitingForTouches) delete t.waitingForTouches
|
|
1165
|
+
})
|
|
1166
|
+
} catch (e) { console.error('[task-board] touches 展示态计算异常(本轮跳过):', String(e)) }
|
|
1167
|
+
// ⑤ UI 池状态:来自活跃 run(一次性模型:没有成员名册,只有在跑的任务)。
|
|
516
1168
|
// hook run 也归「verifiers」区展示(同一格里都是非 Worker 的一次性 run)。
|
|
517
|
-
|
|
518
|
-
|
|
1169
|
+
try {
|
|
1170
|
+
d.poolStatus = { workers: [], verifiers: [] }
|
|
1171
|
+
Object.keys(runs).forEach(function (k) { var rc = runs[k]; if (!rc) return; d.poolStatus[rc.role === 'worker' ? 'workers' : 'verifiers'].push({ id: k, num: '-', busy: true, taskId: rc.taskId, runId: String(rc.id), done: 0, queueLen: 0, suspect: false, model: rc.model || '' }) })
|
|
1172
|
+
} catch (e) { console.error('[task-board] 池状态快照异常(本轮跳过):', String(e)) }
|
|
519
1173
|
if (info.length > 0) { d.dispatchInfo = info.join('; '); d.dispatchInfoAt = new Date().toISOString() }
|
|
520
1174
|
else if (d.dispatchInfo && (!d.dispatchInfoAt || Date.now() - new Date(d.dispatchInfoAt).getTime() > 90000)) { delete d.dispatchInfo; delete d.dispatchInfoAt } // 瞬时通知:90s TTL 过期即清,不再永久残留
|
|
521
1175
|
return d
|
|
522
1176
|
}, true) // skipKick:poolCycle 自写不触发 kickCycle(防无限循环)
|
|
523
1177
|
|
|
524
1178
|
// 锁外 spawn(慢操作);占位 claim 已保证不会被别的 cycle 重复派发
|
|
1179
|
+
// ===== 占位回收统一出口(task-muwlepg6)=====
|
|
1180
|
+
// 四种占位各有归宿,且都是**幂等**的(只清「还是占位态」的那一格,已被真实 run id 覆写的绝不碰——
|
|
1181
|
+
// 那说明这一项其实派发成功了,回收动作会误杀一个真在跑的 run)。抽成一个出口是为了让
|
|
1182
|
+
// 「spawn 返回 null(失败)」与「spawn 之后任一环抛异常」走同一条清理路径:
|
|
1183
|
+
// worker → 回 pending(不占 Worker 并发位,等下一轮重派);
|
|
1184
|
+
// verifier → 清 verifierRun(下轮 cycle 重试;占位不清会永远卡住 Verifier 派发);
|
|
1185
|
+
// hook-pre → 退回 idle + 重新置待跑标记(串行闸门保持关闭,下轮自动重试);
|
|
1186
|
+
// hook-post → 清幂等占用位 + 保留待跑标记(下轮自动重试收口)。
|
|
1187
|
+
async function releaseSpawnPlaceholder(sid, sp, note) {
|
|
1188
|
+
try {
|
|
1189
|
+
if (sp.role === 'worker') {
|
|
1190
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t && t.status === 'in-progress' && t.claimedBy === 'spawn-pending') { t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, 'in-progress', 'pending', 'system', note) }; return t }, true)
|
|
1191
|
+
} else if (sp.role === 'verifier') {
|
|
1192
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t && t.verifierRun === 'spawn-pending') { t.verifierRun = null; delete t.verifierRunAt }; return t }, true)
|
|
1193
|
+
} else if (sp.role === 'hook-pre') {
|
|
1194
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t && t.hooks && t.hooks.pre && t.hooks.pre.state === 'running') { t.hooks.pre.state = 'idle'; t.hooks.pre.runId = null; t.hooks.pre.pending = true; delete t.hooks.pre.runAt }; return t }, true)
|
|
1195
|
+
} else if (sp.role === 'hook-post') {
|
|
1196
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t) { if (t.verifierRun === 'spawn-pending') { t.verifierRun = null; delete t.verifierRunAt } if (t.hooks && t.hooks.post && t.hooks.post.state !== 'done') t.hooks.post.pending = true }; return t }, true)
|
|
1197
|
+
}
|
|
1198
|
+
} catch (e) { console.error('[task-board] 占位回收失败 (task ' + sp.t.id + ' ' + sp.role + '):', String(e)) }
|
|
1199
|
+
}
|
|
525
1200
|
for (var k = 0; k < toSpawn.length; k++) {
|
|
526
1201
|
var sp = toSpawn[k]
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1202
|
+
// 单项隔离(池冻结防御):派发一项牵动 spawn / 落卡 / 回执多个 await,任一环抛异常都只算这一项
|
|
1203
|
+
// 失败——先清掉它的占位(占位不清 = 这张卡永久卡住,verifier/hook 位尤其没有任何自愈路径),
|
|
1204
|
+
// 然后继续 toSpawn 的其余项。旧形状下一项异常会抛穿整个循环:后面的卡全部留在 'spawn-pending',
|
|
1205
|
+
// 且这一轮的 toSpawn 全废(worker 位还能靠 isOrphan 2min 回收,verifier/hook 位就真的死了)。
|
|
1206
|
+
try {
|
|
1207
|
+
var rec = null
|
|
1208
|
+
var resumeFallback = ''
|
|
1209
|
+
// ===== 重派优先走「冷复活续跑」(卡2)=====
|
|
1210
|
+
// 只在 Worker 角色 + workerContinuable 开(缺省)时尝试:命中「上次 continuable run 结局=超时/失败」
|
|
1211
|
+
// 的任务时 sendMessage 唤醒原 child(不 spawn 新会话)。不适用(null)或失败(fallback)都落到下面的
|
|
1212
|
+
// fresh spawn——开关关掉时这段整段短路,一次性回退路径因此逐字不变(断言⑤)。
|
|
1213
|
+
if (sp.role === 'worker' && c.workerContinuable !== false) {
|
|
1214
|
+
var rr = await tryResumeWorker(sid, sp.t)
|
|
1215
|
+
if (rr && rr.rec) rec = rr.rec
|
|
1216
|
+
else if (rr && rr.fallback) resumeFallback = rr.fallback
|
|
1217
|
+
}
|
|
1218
|
+
// 续跑失败才回退 fresh spawn:**先把说明写进任务消息再 spawn**——新 Worker 的首条 prompt 由
|
|
1219
|
+
// buildWorkerPrompt 从任务对象组装(含 messages),这样它自己就能看到「上一轮续跑为何没接上」,
|
|
1220
|
+
// 而不是只留在详情页给人事后翻账。返回的 t 是写盘后那份,直接用它 spawn。
|
|
1221
|
+
if (!rec && resumeFallback) {
|
|
1222
|
+
try {
|
|
1223
|
+
var noted = await mutateLocked(sid, function (d) {
|
|
1224
|
+
var t2 = d.tasks.find(function (x) { return x.id === sp.t.id })
|
|
1225
|
+
if (!t2) return null
|
|
1226
|
+
if (!Array.isArray(t2.messages)) t2.messages = []
|
|
1227
|
+
t2.messages.push({ kind: 'resume-fallback', text: '断点续跑不可用(' + String(resumeFallback).slice(0, 300) + '),已回退全新 Worker 重跑。', at: new Date().toISOString(), by: 'system' })
|
|
1228
|
+
return { t: t2 }
|
|
1229
|
+
}, true)
|
|
1230
|
+
if (noted && noted.t) sp.t = noted.t
|
|
1231
|
+
} catch (e) { console.error('[task-board] 续跑回退说明落卡失败 (task ' + sp.t.id + '):', String(e)) }
|
|
1232
|
+
}
|
|
1233
|
+
if (!rec) rec = await spawnOneShot(sid, sp.t, sp.role)
|
|
1234
|
+
if (rec) {
|
|
1235
|
+
// claim 占位换成真实 run id;verifier/hook-post run 单独记(claimedBy 保留 worker 的,供详情页跳转会话)
|
|
1236
|
+
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)
|
|
1237
|
+
// 派发即回执:spawn 真成功后才入队(占位阶段失败不通知);经 deps 注入,未注入静默跳过(老 host 兼容)
|
|
1238
|
+
// 回执开关(设置区「通知」):notifyDispatch=false → 派发回执整条跳过(spawn 照常,只闭嘴);
|
|
1239
|
+
// 闸门读本轮 poolCycle 已取的 cfg 快照 c(无额外读盘),老看板缺字段 → cfg 归一为 true。
|
|
1240
|
+
if (c.notifyDispatch !== false && typeof notifyDispatched === 'function') notifyDispatched(sid, sp.t, sp.role)
|
|
1241
|
+
} else if (sp.role === 'worker') {
|
|
1242
|
+
// spawn 失败 → 回 pending
|
|
1243
|
+
await releaseSpawnPlaceholder(sid, sp, 'spawn 失败,回收重新排队')
|
|
1244
|
+
pushSysNote(sid, '任务「' + sp.t.title + '」Worker 启动失败,已重新排队')
|
|
1245
|
+
} else if (sp.role === 'verifier') {
|
|
1246
|
+
// verifier spawn 失败 → 清占位,下轮 cycle 重试(占位不清会永远卡住派发)
|
|
1247
|
+
await releaseSpawnPlaceholder(sid, sp, 'spawn 失败')
|
|
1248
|
+
pushSysNote(sid, '任务「' + sp.t.title + '」Verifier 启动失败,下轮自动重试')
|
|
1249
|
+
} else if (sp.role === 'hook-pre') {
|
|
1250
|
+
// hook-pre spawn 失败 → 退回 idle + 重新置待跑标记,下轮自动重试(串行闸门保持关闭)
|
|
1251
|
+
await releaseSpawnPlaceholder(sid, sp, 'spawn 失败')
|
|
1252
|
+
pushSysNote(sid, '史诗「' + sp.t.title + '」前置 hook 启动失败,下轮自动重试')
|
|
1253
|
+
} else if (sp.role === 'hook-post') {
|
|
1254
|
+
// hook-post spawn 失败 → 清幂等占用 + 保留待跑标记,下轮自动重试
|
|
1255
|
+
await releaseSpawnPlaceholder(sid, sp, 'spawn 失败')
|
|
1256
|
+
pushSysNote(sid, '史诗「' + sp.t.title + '」收口 hook 启动失败,下轮自动重试')
|
|
1257
|
+
}
|
|
1258
|
+
} catch (e) {
|
|
1259
|
+
console.error('[task-board] 派发单项异常(已回收占位,继续本轮其余项)(task ' + sp.t.id + ' ' + sp.role + '):', String(e))
|
|
1260
|
+
await releaseSpawnPlaceholder(sid, sp, 'spawn 异常,回收重新排队')
|
|
1261
|
+
try { pushSysNote(sid, '任务「' + sp.t.title + '」' + sp.role + ' 派发异常,已回收占位待重派') } catch (_) {}
|
|
551
1262
|
}
|
|
552
1263
|
}
|
|
553
1264
|
return result
|
|
554
1265
|
}
|
|
555
1266
|
|
|
556
|
-
// 插件停止时清理所有活跃 run
|
|
557
|
-
|
|
1267
|
+
// 插件停止时清理所有活跃 run。
|
|
1268
|
+
// continuable rec 没有 run(持久子会话,本卡不 interrupt)——两件事必须做:
|
|
1269
|
+
// ① 判空(否则 rr[k].run.dispose() 抛 TypeError,虽被 catch 但整条清理链断在这里);
|
|
1270
|
+
// ② 立 settled 旗(让软/硬超时臂与事件结算一起放行,卸载后不该再有状态推进)。
|
|
1271
|
+
// 表的处置保持既有语义:逐会话原位清空(旧引用不会看到残留 rec)+ 顶层容器换新(与原实现的
|
|
1272
|
+
// state.activeRuns = {} 等价)。原位清空是必要的——runsFor(sid) 返回的是每会话子对象,
|
|
1273
|
+
// 只换顶层容器会把这些子对象连同里面的 rec 一起漏掉。
|
|
1274
|
+
ctx.effect(function () { return function () { Object.keys(state.activeRuns).forEach(function (psid) { var rr = state.activeRuns[psid]; Object.keys(rr).forEach(function (k) { try { var r0 = rr[k]; if (r0 && r0.run) r0.run.dispose(); if (r0) r0.settled = true } catch (_) {} ; delete rr[k] }) }); state.activeRuns = {}; state.reconcileDone = {} } })
|
|
558
1275
|
// Host 侧调度心跳:每 15s 对所有已知会话跑 poolCycle(客户端轮询只是触发器之一,面板关闭/后台节流时照常运转)
|
|
559
1276
|
;(function () { var tm = ctx.timer; if (!tm) return; var disposeTick = tm.interval(function () { var cutoff = Date.now() - 1800000; Object.keys(knownSessions).forEach(function (sid) { if (knownSessions[sid] < cutoff) delete knownSessions[sid]; else poolCycle(sid).catch(function () {}) }) }, 15000); ctx.effect(function () { return disposeTick }) })()
|
|
560
1277
|
|
|
@@ -576,38 +1293,15 @@ export function createDispatch(ctx, state, deps) {
|
|
|
576
1293
|
if (!teamModeCache[String(agent.id)]) return ''
|
|
577
1294
|
// 第 6 条(拆分条款)走 epicSplit 门禁(缺省 true = 逐字不变):关掉只是不再主动劝拆,
|
|
578
1295
|
// 显式 parentId 建子卡 / 史诗自动收口 / hooks 状态机全部照常(机制不禁)。
|
|
579
|
-
return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles
|
|
1296
|
+
return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles 把路径带上(可写「路径:L1-L2 — 一句用途」,只给行号不给正文)——两者都会随子代理的首条 prompt 一次性注入(调研笔记全文 + 文件清单),文件内容由子代理按需用 read 工具按行号范围自取。子代理是全新会话、无你的会话记忆,上下文不够它需要从零自行调研,效率大打折扣甚至跑偏方向——开发类任务(代码改动/修复/特性)务必带文件调研,实测可省 Worker 10~15 分钟自行 grep 定位;未带调研上下文的开发类任务返回会附 warning。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. Team 模式下 task_create 默认建为草稿(草稿不会被派发领取)。把所有任务的 dependsOn 依赖关系、contextNotes/contextFiles 都补完后,再逐个 task_update publish=true 统一发布。确实需要立即派发的单个任务才显式传 draft:false。' + splitRuleOf(epicSplitOn(String(agent.id))) + (feedbackOn(String(agent.id)) ? '\n' + LESSON_RECALL_HINT + '把检索到的相关历史教训写进任务的 contextNotes,让子代理少踩重复的坑。' : '')
|
|
580
1297
|
},
|
|
581
1298
|
})
|
|
582
1299
|
ctx.effect(function () { return disposeSection })
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
name: 'task-board:context-pack',
|
|
588
|
-
order: 50,
|
|
589
|
-
text: function (assembleCtx) {
|
|
590
|
-
var agent = assembleCtx && assembleCtx.agent
|
|
591
|
-
if (!agent) return ''
|
|
592
|
-
var aid = String(agent.id)
|
|
593
|
-
var hit = packByChild[aid]
|
|
594
|
-
if (hit) return hit
|
|
595
|
-
// 首轮竞速自愈:start() 返回前的首次组装按父子归属从 pendingPacks 认领
|
|
596
|
-
var agentsSvc = ctx.agents
|
|
597
|
-
if (!agentsSvc) return ''
|
|
598
|
-
var now = Date.now()
|
|
599
|
-
for (var i = pendingPacks.length - 1; i >= 0; i--) {
|
|
600
|
-
var pp = pendingPacks[i]
|
|
601
|
-
if (now - pp.at > 60000) { pendingPacks.splice(i, 1); continue }
|
|
602
|
-
try {
|
|
603
|
-
if (agentsSvc.isOwnedBy(aid, pp.parent)) { packByChild[aid] = pp.pack; return pp.pack }
|
|
604
|
-
} catch (e) { console.error('[task-board] ctxpack isOwnedBy threw: ' + String(e)) }
|
|
605
|
-
}
|
|
606
|
-
return ''
|
|
607
|
-
},
|
|
608
|
-
})
|
|
609
|
-
ctx.effect(function () { return disposeCtxPack })
|
|
1300
|
+
// 预研「上下文注入」区块通道已整体退役(task-muvjs392):瘦身清单直接拼进首条 prompt,
|
|
1301
|
+
// 这里不再注册任何 systemPrompt.context 动态注入段——旧形态随 runtime 快照每轮刷新重发
|
|
1302
|
+
// (实证 6×48.8K 字符≈白烧 75–100K token),且「按父子归属认领预研包」的首轮竞速机制
|
|
1303
|
+
// 在并行 spawn 下存在认领错包的潜伏 bug,一并消灭。
|
|
610
1304
|
}
|
|
611
1305
|
|
|
612
|
-
return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, readContextPack: readContextPack }
|
|
1306
|
+
return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack }
|
|
613
1307
|
}
|