dsh-agent-board 1.9.1 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/lib/core.mjs +13 -2
- package/lib/dispatch.mjs +103 -1544
- package/lib/hung.mjs +202 -0
- package/lib/model-health.mjs +553 -0
- package/lib/rpc.mjs +7 -3
- package/lib/settle.mjs +954 -0
- package/package.json +1 -1
- package/test/core.test.mjs +117 -73
- package/test/fault-injection-docs.test.mjs +26 -8
- package/test/lane-gui-bysize.test.mjs +123 -0
- package/test/runtime-health.test.mjs +2 -1
- package/test/scorecard-data.test.mjs +2 -1
- package/test/settle-stop-interrupt.test.mjs +20 -11
package/lib/dispatch.mjs
CHANGED
|
@@ -3,52 +3,43 @@
|
|
|
3
3
|
// token 消耗累加 / poolCycle 派发周期(孤儿回收+占位 claim+池快照)/ 15s 心跳 / 插件卸载清理 /
|
|
4
4
|
// Team 模式 systemPrompt 引导段 / 预研上下文瘦身清单(调研笔记全文 + 文件清单,随首条 prompt 一次性注入)/
|
|
5
5
|
// 可续跑 Worker(continuable:硬超时 interrupt 留存 + 重派 sendMessage 冷复活续跑 + 失败回退 fresh spawn)。
|
|
6
|
+
// 拆分第 7 刀 task-mvt8c1yx-7:usage 水位(localDayKey/effectiveOf/seekSeqOf/num0/hasUsageRecorded)/
|
|
7
|
+
// accumulateRunUsage / 重启 reconcile(reconcileRoot)/ 僵尸留档清扫(sweepZombieRuns)/ 幽灵占位回收
|
|
8
|
+
// (recIsLive/reapGhostRecs)搬至 lib/settle.mjs——poolCycleBody 三挂点与 return API 的 accumulateRunUsage
|
|
9
|
+
// 重指 settle 实例(reconcile 域闭包 deps withTimeout/armTimeouts/logSizeOf 随工厂入参注入 settle)。
|
|
6
10
|
import { statSync } from 'node:fs'
|
|
7
11
|
import * as core from './core.mjs'
|
|
8
|
-
import {
|
|
9
|
-
import { splitRuleOf,
|
|
10
|
-
|
|
12
|
+
import { findRunLog } from './usage.mjs'
|
|
13
|
+
import { splitRuleOf, DECOMPOSE_SCHEMA, buildDecomposerPrompt, TEAM_DECOMPOSE_FLOW } from './policy.mjs'
|
|
14
|
+
import { createHung } from './hung.mjs'
|
|
15
|
+
import { createSettle } from './settle.mjs'
|
|
16
|
+
// 模型健康域(拆分第 2 刀 task-mvt8c1yx-2 + 第 3 刀 task-mvt8c1yx-3 + 第 4 刀 task-mvt8c1yx-4):冷却嗅探纯函数
|
|
17
|
+
// (parseModelCooldown/hhmmOf/modelKey)、健康表/冷却登记/模型目录/备用链健康判定/熔断连击工厂(createModelHealth)、
|
|
18
|
+
// 主动探测域(probeOnce/probeModel/probeConfiguredModels/maybeProbeModels/探测态/disposeProbe)全量已拆至
|
|
19
|
+
// lib/model-health.mjs(叶子模块,与健康同域;health→hung 单向 import 无环,cfg 由 core 直接 import)。
|
|
20
|
+
// hhmmOf/modelKey 在本文件不搬区段(降档留痕/熔断键)仍有调用点——单源 import 同源使用;CONNECTIVITY_RE 随第 4 刀迁出(唯一代码消费点 classifyEmptyResponse 同域随迁),本文件不再 import。
|
|
21
|
+
// applyDecomposeProposal/pushRejectLesson 与 isHungKillFailure/HUNG_CIRCUIT_WHY 随第 6 刀(task-mvt8c1yx-6)
|
|
22
|
+
// 搬至 lib/settle.mjs 消费(角色结算搬移),本文件不再 import。
|
|
23
|
+
import { parseModelCooldown, createModelHealth, hhmmOf, modelKey } from './model-health.mjs'
|
|
24
|
+
const { ah, cfg, claimApply, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, hookOn, hookSetState, gsb, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER, opsLogLine } = core
|
|
11
25
|
|
|
12
26
|
// ===== 模型冷却嗅探:限流(task-mvneq4kk)+ 连接/网关故障短窗(task-mvnx8hnm)=====
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
// ② 连接/网关形态(kind='connectivity'):命中 CONNECTIVITY_RE → until = now + 10min 固定短窗
|
|
19
|
-
// (连接故障多为瞬时,短窗自动恢复——比限流缺省 30min 短;固定不开放配置,要配置化等实证再开)。
|
|
20
|
-
// 判定顺序:先限流后连接(两类关键词并存的文本按限流处理,窗口更保守)。
|
|
21
|
-
// 英文裸词 timeout 刻意不在连接形态内(只认 ETIMEDOUT errno 形态)——供应商「provider timeout after 30s」
|
|
22
|
-
// 类软性报错与 withTimeout 的「label timeout Nms」硬超时文本都不进冷却(C5 既有断言锁定该口径)。
|
|
23
|
-
var RATE_LIMIT_RE = /429|RATE_LIMIT|rate.?limit|限额|额度/i
|
|
24
|
-
var RATE_RESET_ZH_RE = /(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})\s*重置/
|
|
25
|
-
var RATE_RESET_EN_RE = /resets?(?:\s+at|\s+on)?\s+(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})/i
|
|
26
|
-
var RATE_LIMIT_DEFAULT_MS = 30 * 60000
|
|
27
|
-
var CONNECTIVITY_RE = /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|ECONNRESET|EAI_AGAIN|fetch failed|network|socket|502|503|504|网关|连接|超时/i
|
|
28
|
-
var CONNECTIVITY_COOLDOWN_MS = 10 * 60000
|
|
29
|
-
export function parseModelCooldown(errText) {
|
|
30
|
-
var s = String(errText || '')
|
|
31
|
-
if (RATE_LIMIT_RE.test(s)) {
|
|
32
|
-
var m = s.match(RATE_RESET_ZH_RE) || s.match(RATE_RESET_EN_RE)
|
|
33
|
-
if (m) {
|
|
34
|
-
var ts = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])).getTime()
|
|
35
|
-
if (isFinite(ts)) return { until: ts, parsed: true, kind: 'rate-limit' }
|
|
36
|
-
}
|
|
37
|
-
return { until: Date.now() + RATE_LIMIT_DEFAULT_MS, parsed: false, kind: 'rate-limit' }
|
|
38
|
-
}
|
|
39
|
-
if (CONNECTIVITY_RE.test(s)) return { until: Date.now() + CONNECTIVITY_COOLDOWN_MS, parsed: false, kind: 'connectivity' }
|
|
40
|
-
return null
|
|
41
|
-
}
|
|
42
|
-
// 兼容层(task-mvnx8hnm):旧导出名保留为别名,既有调用点(测试/潜在宿主消费方)零改动
|
|
27
|
+
// 纯函数域(常量/parseModelCooldown/hhmmOf/modelKey)与健康表/冷却登记(createModelHealth)已拆至
|
|
28
|
+
// lib/model-health.mjs(拆分第 2 刀 task-mvt8c1yx-2,叶子模块——上方 import 同源使用)。
|
|
29
|
+
// 此处保留兼容层(task-mvnx8hnm):旧导出名保留,既有调用点(测试/潜在宿主消费方)零改动——
|
|
30
|
+
// parseRateLimit 与 parseModelCooldown 同一绑定(test/rate-limit-cooldown.test.mjs L68 断言引用相等)。
|
|
31
|
+
export { parseModelCooldown }
|
|
43
32
|
export var parseRateLimit = parseModelCooldown
|
|
44
|
-
// 冷却消息「至 HH:mm」格式化(本地时区)
|
|
45
|
-
function hhmmOf(ts) { var d = new Date(ts); return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) }
|
|
46
33
|
|
|
47
34
|
export function createDispatch(ctx, state, deps) {
|
|
48
35
|
// 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
|
|
49
36
|
// 清单只给「路径:L行号 — 一句用途」,文件内容由 Worker 自己用 read 工具按行号范围自取。
|
|
50
37
|
var rt = deps.rt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
|
|
51
38
|
var rootForSession = deps.rootForSession, withTimeout = deps.withTimeout, runsFor = deps.runsFor, feedbackOn = deps.feedbackOn
|
|
39
|
+
// 挂死检测实例(拆分第 1 刀 task-mvt8c1yx):常量/帧活性/零产出清扫已拆至 lib/hung.mjs(叶子模块)。
|
|
40
|
+
// logSizeOf 仍在 createDispatch 内;settleRun 已拆至 settle.mjs,此处以懒闭包重指 settle 实例;
|
|
41
|
+
// createHung 本身不立即调用 settleRun,下方 health 初始化后即完成 settle 实例接线。
|
|
42
|
+
var hung = createHung({ state: state, mutateLocked: mutateLocked, runsFor: runsFor, logSizeOf: logSizeOf, settleRun: function () { return settle.settleRun.apply(null, arguments) } })
|
|
52
43
|
// 会话日志根(可选注入):usage 增量结算(卡3)必须「真读真日志」才测得出重复计账——
|
|
53
44
|
// 生产不注入(usage.mjs 回退 os.homedir()),单测注入临时目录(否则只能开子进程改 HOME,
|
|
54
45
|
// 而同进程改 os.homedir 会污染整个测试进程)。老宿主/老测试桩无此依赖 → undefined,行为不变。
|
|
@@ -66,451 +57,42 @@ export function createDispatch(ctx, state, deps) {
|
|
|
66
57
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
67
58
|
var dispatchedEver = state.dispatchedEver
|
|
68
59
|
var badModels = state.badModels
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// ③ 探测超时/预算耗尽只更新 note(判定保留,task-mvsv8zym):observed/state/latencyMs/recentRuns
|
|
106
|
-
// 全部保持原值——避免已 healthy/degraded 条目被超时倒退成 unknown(超时非模型健康信号)。
|
|
107
|
-
if (opts.preserveObserved) {
|
|
108
|
-
if (opts.probe) {
|
|
109
|
-
e.lastProbe = at
|
|
110
|
-
e.observedNote = '主动探测超时(慢模型,判定保留)' + (opts.note ? ':' + opts.note : '')
|
|
111
|
-
e.note = e.observedNote
|
|
112
|
-
}
|
|
113
|
-
return syncModelHealth(sid, model)
|
|
114
|
-
}
|
|
115
|
-
// task-mvrqdw4k:探测空响应分治——ok=true 🟢健康;opts.degraded 🟡降级(链路存活,供应商怪癖,
|
|
116
|
-
// 只由探测降级存活臂置入);其余照旧 unknown(失败臂)。被动 run 观察臂语义逐字不变。
|
|
117
|
-
e.observed = ok ? 'healthy' : (opts.degraded ? 'degraded' : 'unknown')
|
|
118
|
-
if (isFinite(latency) && latency >= 0) e.latencyMs = Math.round(latency)
|
|
119
|
-
if (opts.probe) {
|
|
120
|
-
e.lastProbe = at
|
|
121
|
-
e.observedNote = ok ? '主动探测通过'
|
|
122
|
-
: (opts.degraded
|
|
123
|
-
? '主动探测:链路存活但极简请求零内容(EMPTY_RESPONSE 供应商怪癖)' + (opts.note ? ':' + opts.note : '')
|
|
124
|
-
: '主动探测失败' + (opts.note ? ':' + opts.note : ''))
|
|
125
|
-
} else {
|
|
126
|
-
e.recentRuns.push({ ok: !!ok, at: at, latencyMs: isFinite(latency) && latency >= 0 ? Math.round(latency) : null })
|
|
127
|
-
if (e.recentRuns.length > 5) e.recentRuns = e.recentRuns.slice(-5)
|
|
128
|
-
var passed = e.recentRuns.filter(function (r) { return r.ok }).length
|
|
129
|
-
e.observedNote = (ok ? '最近 run 成功' : '最近 run 失败') + '(近 ' + e.recentRuns.length + ' 次 ' + passed + ' 次成功)'
|
|
130
|
-
}
|
|
131
|
-
e.note = e.observedNote
|
|
132
|
-
return syncModelHealth(sid, model)
|
|
133
|
-
}
|
|
134
|
-
function markModelCircuit(sid, model, note) {
|
|
135
|
-
var e = healthEntry(sid, model)
|
|
136
|
-
e.circuitNote = note || '连续验收失败,模型熔断中'
|
|
137
|
-
return syncModelHealth(sid, model)
|
|
138
|
-
}
|
|
139
|
-
// 冷却期判定:返回冷却截止 ts(0=不在冷却);过期项惰性清除(读路径顺手删,无需 sweeper)
|
|
140
|
-
function cooldownLive(model) {
|
|
141
|
-
var until = modelCooldown[model]
|
|
142
|
-
if (!until) return 0
|
|
143
|
-
if (Date.now() >= until) { delete modelCooldown[model]; return 0 }
|
|
144
|
-
return until
|
|
145
|
-
}
|
|
146
|
-
// 冷却嗅探登记:两条登记路径共用(①spawn extras 回退臂 e/e2 文本 ②settleWorker 失败臂 errText——
|
|
147
|
-
// activation.result 拆除兜底臂带上来的诊断文本也走②)。max 收敛:多次报错只延不缩
|
|
148
|
-
// (带重置时间的精确值与 30min/10min 缺省并存时取更晚者);截止未后延则零改动零留痕(防重复报错刷屏)。
|
|
149
|
-
// 留痕分态(task-mvnx8hnm):限流=「额度冷却至 HH:mm」,连接/网关=「连接故障短冷却 10 分钟」——
|
|
150
|
-
// 卡 messages 各记一条 kind='cooldown',排查时一眼分清「额度没了」与「连不上」。
|
|
151
|
-
// 写卡失败静默吞掉(留痕是附属动作,绝不阻断 spawn 回退/失败结算主流程)。返回冷却截止 ts,未命中 null。
|
|
152
|
-
async function sniffModelCooldown(sid, taskId, model, errText) {
|
|
153
|
-
if (!model) return null
|
|
154
|
-
var hit = parseModelCooldown(errText)
|
|
155
|
-
if (!hit) return null
|
|
156
|
-
if (hit.until <= (modelCooldown[model] || 0)) { syncModelHealth(sid, model, modelCooldown[model]); return modelCooldown[model] }
|
|
157
|
-
modelCooldown[model] = hit.until
|
|
158
|
-
var until = modelCooldown[model]
|
|
159
|
-
var he = healthEntry(sid, model)
|
|
160
|
-
he.cooldownNote = hit.kind === 'connectivity' ? ('连接/网关故障,冷却至 ' + hhmmOf(until)) : ('额度限流,冷却至 ' + hhmmOf(until))
|
|
161
|
-
syncModelHealth(sid, model, until)
|
|
162
|
-
if (hit.kind === 'connectivity') console.error('[task-board] 模型 ' + model + ' 连接/网关故障,短冷却 10 分钟至 ' + new Date(until).toISOString())
|
|
163
|
-
else console.error('[task-board] 模型 ' + model + ' 命中 RATE_LIMIT 限流,冷却至 ' + new Date(until).toISOString() + '(' + (hit.parsed ? '重置时间解析' : '缺省 30min') + ')')
|
|
164
|
-
try {
|
|
165
|
-
await mutateLocked(sid, function (d) {
|
|
166
|
-
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
167
|
-
if (!t) return null
|
|
168
|
-
if (!Array.isArray(t.messages)) t.messages = []
|
|
169
|
-
t.messages.push({ kind: 'cooldown', text: hit.kind === 'connectivity' ? ('🔌 模型 ' + model + ' 连接故障短冷却 10 分钟(至 ' + hhmmOf(until) + ')') : ('⏳ 模型 ' + model + ' 额度冷却至 ' + hhmmOf(until)), at: new Date().toISOString(), by: 'system' })
|
|
170
|
-
return { ok: true }
|
|
171
|
-
}, true) // skipKick:只是留痕,不踢 poolCycle
|
|
172
|
-
} catch (_) {}
|
|
173
|
-
return until
|
|
174
|
-
}
|
|
175
|
-
// ===== 模型目录获取(task-mvd3li6 目录失效闸数据源):与 rpc.mjs enumModels 同源(llm.listProviders+listModels)=====
|
|
176
|
-
// 返回 { "provider/model": true } 目录映射;null = 目录不可得 → 目录闸整体跳过(保守语义③,不误伤):
|
|
177
|
-
// ① llm 服务缺失/缺 listProviders/listModels 方法(老宿主);② listProviders 未返回数组或抛错;
|
|
178
|
-
// ③ 任一 provider 的 listModels 抛错或未返回数组(部分枚举=目录不完整,宁可跳过闸也不误杀在册模型);
|
|
179
|
-
// ④ 枚举结果为空(退化形态:网关零模型时全候选都会被误判目录外,同样保守跳过)。
|
|
180
|
-
// 每次 spawnOneShot/续跑闸现取一次(刻意不缓存:模型从宿主配置删除/改名要当轮生效,缓存会放走已删模型)。
|
|
181
|
-
async function modelDirectory() {
|
|
182
|
-
var llm = ctx.get && ctx.get('llm')
|
|
183
|
-
if (!llm || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') return null
|
|
184
|
-
var providers = null
|
|
185
|
-
try { providers = llm.listProviders() } catch (_) { return null }
|
|
186
|
-
if (!Array.isArray(providers)) return null
|
|
187
|
-
var out = {}
|
|
188
|
-
for (var i = 0; i < providers.length; i++) {
|
|
189
|
-
try {
|
|
190
|
-
var models = await llm.listModels(providers[i].id)
|
|
191
|
-
if (!Array.isArray(models)) return null
|
|
192
|
-
for (var j = 0; j < models.length; j++) { if (models[j] && models[j].id) out[providers[i].id + '/' + models[j].id] = true }
|
|
193
|
-
} catch (_) { return null } // 任一 provider 枚举失败 → 目录不完整 → 保守跳过(不误伤)
|
|
194
|
-
}
|
|
195
|
-
if (!Object.keys(out).length) return null
|
|
196
|
-
return out
|
|
197
|
-
}
|
|
198
|
-
// 存在闸谓词(同步;dir=modelDirectory() 结果):目录可得且模型不在目录 → true(配置已失效)。
|
|
199
|
-
// dir 为 null(目录不可得)恒 false——闸整体跳过,只留连败软闸生效。
|
|
200
|
-
function outOfDir(modelId, dir) { return !!(dir && modelId && !dir[modelId]) }
|
|
201
|
-
// ===== 目录失效留痕(task-mvd3li6):取模候选不在当前模型目录 → 卡 messages 记「配置已失效」,per 模型去重 =====
|
|
202
|
-
// 同模型重复取模只记一条(防每轮重派刷屏,与冷却嗅探「未后延零留痕」同纪律);
|
|
203
|
-
// 顺带在健康表 observedNote 落同款分态(GUI 健康点可读出「为什么被跳过」)。写卡失败静默吞掉
|
|
204
|
-
// (留痕是附属动作,绝不阻断取模主流程)。
|
|
205
|
-
async function traceStaleModel(sid, taskId, model) {
|
|
206
|
-
if (!model) return
|
|
207
|
-
console.error('[task-board] 配置已失效:' + model + '(已从模型目录移除),取模跳过该候选')
|
|
208
|
-
try {
|
|
209
|
-
var he = healthEntry(sid, model)
|
|
210
|
-
if (he.observedNote !== '配置已失效(已从模型目录移除)') { he.observedNote = '配置已失效(已从模型目录移除)'; he.note = he.observedNote }
|
|
211
|
-
} catch (_) {}
|
|
212
|
-
try {
|
|
213
|
-
await mutateLocked(sid, function (d) {
|
|
214
|
-
var t2 = d.tasks.find(function (x) { return x.id === taskId })
|
|
215
|
-
if (!t2) return null
|
|
216
|
-
if (!Array.isArray(t2.messages)) t2.messages = []
|
|
217
|
-
var text = '⚠️ 配置已失效:' + model + '(已从模型目录移除),本 run 取模跳过'
|
|
218
|
-
for (var i = t2.messages.length - 1; i >= 0; i--) {
|
|
219
|
-
if (t2.messages[i] && t2.messages[i].kind === 'stale-model' && t2.messages[i].text === text) return null // 已记过,零改动零留痕
|
|
220
|
-
}
|
|
221
|
-
t2.messages.push({ kind: 'stale-model', text: text, at: new Date().toISOString(), by: 'system' })
|
|
222
|
-
return { ok: true }
|
|
223
|
-
}, true) // skipKick:只是留痕,不踢 poolCycle
|
|
224
|
-
} catch (_) {}
|
|
225
|
-
}
|
|
226
|
-
// ===== 备用模型链健康判定(task-mvnqxlpn):不在 badModels 熔断名单且不在 modelCooldown 冷却期 =====
|
|
227
|
-
// 两张表各管各的(熔断=连续失败布尔,冷却=额度窗口时间戳),任一命中即不健康。
|
|
228
|
-
// 消费点=spawnOneShot 取模链:候选被判不健康时先扫板级 fallbackModels 找首个健康者(链本身逐项过本检查)。
|
|
229
|
-
// ===== 健康面拓宽(task-mvd3li6):在既有熔断/冷却双闸之上增读 modelHealth 聚合表 =====
|
|
230
|
-
// ① 近期连败软闸:聚合表 recentRuns(近 5 次 run 成败,settleRunRecord 每次结算喂给)≥2 且全败 →
|
|
231
|
-
// 不健康(软熔断:不落 badModels 黑名单,仅取模/续跑跳过)。一次成功即复位——recentRuns 滚动
|
|
232
|
-
// 窗口本就有归零语义(成功条目进窗「全败」即不成立),确认复用零新增簿记。原形:glm-5.2
|
|
233
|
-
// 「78s 报错一次+挂死一次」病了还被优先调度/续跑——第二次派发即被本闸拦下改走健康取模链。
|
|
234
|
-
// ② 目录失效闸:dir 可得且模型不在目录(已从宿主配置删除/改名)→ 视为不健康跳过。
|
|
235
|
-
// dir 为可选第三参:既有调用点(不带 dir)= 目录闸跳过,行为逐字不变(零回归)。
|
|
236
|
-
function healthyModel(sid, modelId, dir) {
|
|
237
|
-
if (!modelId) return false
|
|
238
|
-
var h = syncModelHealth(sid, modelId)
|
|
239
|
-
if (h.state === 'circuit') return false
|
|
240
|
-
if (h.state === 'cooldown') return false
|
|
241
|
-
// task-mvrqdw4k:degraded(🟡空响应降级存活)刻意不拦——探测管可调性,模型仍可被路由选用;
|
|
242
|
-
// 行为质量另有 recentRuns 连败软闸兜底(下方),探测放宽不放跑真坏模型。
|
|
243
|
-
if (outOfDir(modelId, dir)) return false
|
|
244
|
-
var rr = h.recentRuns
|
|
245
|
-
if (Array.isArray(rr) && rr.length >= 2) {
|
|
246
|
-
var allBad = true
|
|
247
|
-
for (var i = 0; i < rr.length; i++) { if (rr[i] && rr[i].ok) { allBad = false; break } }
|
|
248
|
-
if (allBad) return false
|
|
249
|
-
}
|
|
250
|
-
return true
|
|
251
|
-
}
|
|
252
|
-
// 扫备用链找首个健康模型(保持链序=主窗口配置的能力水位优先级);链尽/空链返回 ''。
|
|
253
|
-
// task-mvd3li6 起 async + dir + taskId:链上目录外模型跳过并留痕(traceStaleModel,per 模型去重);
|
|
254
|
-
// 目录不可得(dir 缺省/null)零留痕,行为与旧签名逐字一致(既有调用点零回归)。
|
|
255
|
-
async function pickHealthyFallback(sid, fbList, dir, taskId) {
|
|
256
|
-
if (!Array.isArray(fbList)) return ''
|
|
257
|
-
for (var fi = 0; fi < fbList.length; fi++) {
|
|
258
|
-
if (outOfDir(fbList[fi], dir)) { await traceStaleModel(sid, taskId, fbList[fi]); continue }
|
|
259
|
-
if (healthyModel(sid, fbList[fi], dir)) return fbList[fi]
|
|
260
|
-
}
|
|
261
|
-
return ''
|
|
262
|
-
}
|
|
263
|
-
// ===== 熔断连击喂给(task-mvowb2hc 洞①):模型面失败判别 + 连击计数 + 三连拉黑 =====
|
|
264
|
-
// 模型面失败 = 会话被宿主/激活机制在开工前杀掉,两条来源文本都在本文件内确定:
|
|
265
|
-
// ① activation.result 拆除兜底臂:「子会话被宿主拆除(stopReason: X,…)」(stopReason=completed
|
|
266
|
-
// 的收尾性拆除不算——那是正常完成的副产物,不是模型失败);
|
|
267
|
-
// ② activation 异常兜底臂:「子会话 activation 异常:…」(spawn 机制/provider 面失败,同属开工前)。
|
|
268
|
-
// worker 自己的失误(空输出/超时中断/文本降级失败)不算——好模型不该为 worker 的错误背锅。
|
|
269
|
-
var WORKER_MODEL_CIRCUIT_AFTER = 3 // 连续 N 次模型面失败才拉黑(与 worker 三连败阻塞同一三击口径)
|
|
270
|
-
// ===== 挂死零产出检测常量(task-mvqcoz9l)=====
|
|
271
|
-
// 原形(2026-10-27 降级实测):glm-5.2 续跑挂上「连接开着永不响应」形态——会话不死(死会话哨兵
|
|
272
|
-
// listChildren 查不出)、不报错(无 settle 事件)、零产出(日志零增长 7 分钟),running 假相直到
|
|
273
|
-
// 人工终止。熔断/冷却/哨兵全部够不着它(哨兵只盖「会话不在目录」,这里盖「会话在但不干活」)。
|
|
274
|
-
// 判死口径:连续 HUNG_ZERO_GROWTH_ROUNDS 轮(≈40 ≈ 600s/15s 心跳)日志零增长 **且距基线
|
|
275
|
-
// ≥600s 墙钟地板**——poolCycle 不只心跳驱动,每次写盘都 kickCycle(写风暴下轮数可在 1s
|
|
276
|
-
// 内跑完),纯轮数会对刚 spawn 的 run 秒判;双闸 = 「≥40 轮零增长且真跨 600s 墙钟」。
|
|
277
|
-
// 常量化暂不开放配置(实证再调)。
|
|
278
|
-
// ===== 挂死墙钟阈值定稿(task-mvrt9tn5 二度校准):固定 600s,放弃延迟缩放 =====
|
|
279
|
-
// 三轮生产误杀实证(2026-10-28):①慢网关模型长生成段 180s+ 零日志被 180s 闸误杀;
|
|
280
|
-
// ②kimi-k3(健康表实测延迟 2s 的快模型)正常干活 10 分钟同样被 180s 闸误杀(=3×2s 地板)——
|
|
281
|
-
// 结论:延迟(首 token 速度)与「长生成静默时长」无关,慢模型慢在长生成,快模型做大任务
|
|
282
|
-
// 同样有 3 分钟+ 无日志窗口,3×latencyMs 缩放(task-mvrg3962)方向作废。定稿:墙钟地板直接
|
|
283
|
-
// 600s(10 分钟,对齐观测到的最长正常静默),删延迟缩放(简化,也根除「3×0s=0s 假精确回落」
|
|
284
|
-
// 的延迟数据缺失洞)。轮数闸按墙钟换算常量化:600s/15s 心跳 ≈ 40 轮。判死语义不变:
|
|
285
|
-
// 600s 零增长 + 轮数达线才判(真挂死 7 分钟实证,600s 仍能抓住,只是晚 3 分钟——可接受)。
|
|
286
|
-
// 熔断喂连击/重派链路均不动。
|
|
287
|
-
var HUNG_WALL_FLOOR_MS = 600000
|
|
288
|
-
var HUNG_ZERO_GROWTH_ROUNDS = 40 // ≈ HUNG_WALL_FLOOR_MS / 15s 心跳:轮数半边与墙钟半边同尺度(写风暴防秒判语义保留)
|
|
289
|
-
// 挂死检测 v2:assistant-stream 帧活性优先,日志双闸只兜无帧流/不可映射的旧路径。
|
|
290
|
-
// 120s 只标疑似不杀;600s 才走既有判死结算。帧时间戳只在内存刷新,持久化仅承担 sweep 基线与状态转换。
|
|
291
|
-
var HUNG_FRAME_SUSPECT_MS = 120000
|
|
292
|
-
var HUNG_FRAME_KILL_MS = 600000
|
|
293
|
-
var HUNG_KILL_PREFIX = 'run 挂死:' // 判死失败文本前缀(进 isModelKillFailure 白名单 = 计入熔断连击)
|
|
294
|
-
var HUNG_CIRCUIT_WHY = '挂死零产出(会话日志字节持续不涨),已熔断' // 挂死触发的熔断文案分态(挂死≠秒拆)
|
|
295
|
-
// 挂死型模型面失败识别:判死结算的错误文本(HUNG_KILL_PREFIX 开头)与宿主秒拆/activation 异常
|
|
296
|
-
// 同属模型面故障——计入熔断连击(feedModelFailure)。文案避开冷却嗅探关键词(连接/超时/网关等),
|
|
297
|
-
// 挂死只进连击,不登记冷却(冷却表与熔断表语义分治)。
|
|
298
|
-
function isHungKillFailure(errText) { return String(errText || '').indexOf(HUNG_KILL_PREFIX) === 0 }
|
|
299
|
-
function isModelKillFailure(errText) {
|
|
300
|
-
var s = String(errText || '')
|
|
301
|
-
if (isHungKillFailure(s)) return true
|
|
302
|
-
if (s.indexOf('子会话被宿主拆除(stopReason: ') === 0) return s.indexOf('子会话被宿主拆除(stopReason: completed') !== 0
|
|
303
|
-
return s.indexOf('子会话 activation 异常:') === 0
|
|
304
|
-
}
|
|
305
|
-
// 连击 +1;达 N 且尚未拉黑 → badModels 落黑名单(取模链/降档候选的既有熔断判定照常消费,零新分支)
|
|
306
|
-
// + 健康表 circuit + 系统通知(不带 taskId:与验收熔断通知同口径,不受任务回执冲刷丢弃)。
|
|
307
|
-
// why 可选(task-mvqcoz9l):挂死触发的熔断文案分态(挂死≠秒拆),缺省=既有秒拆文案(既有调用点零回归)。
|
|
308
|
-
// 返回是否本次调用触发拉黑(测试/留痕用)。
|
|
309
|
-
function feedModelFailure(sid, model, why) {
|
|
310
|
-
if (!model) return false
|
|
311
|
-
var k = modelKey(sid, model)
|
|
312
|
-
var n = (modelFailStreak[k] || 0) + 1
|
|
313
|
-
modelFailStreak[k] = n
|
|
314
|
-
if (n < WORKER_MODEL_CIRCUIT_AFTER) return false
|
|
315
|
-
if (!badModels[k]) {
|
|
316
|
-
badModels[k] = true
|
|
317
|
-
markModelCircuit(sid, model, why || ('连续 ' + n + ' 次 spawn/续跑即被宿主拆除(模型失败),已熔断'))
|
|
318
|
-
pushSysNote(sid, '模型 ' + model + ' ' + (why || ('连续 ' + n + ' 次 spawn/续跑即被宿主秒拆(模型失败),已熔断')) + '——后续取模链跳过该模型')
|
|
319
|
-
}
|
|
320
|
-
return true
|
|
321
|
-
}
|
|
322
|
-
// 连击归零:任一可归因 run 正常完成即证明模型链路恢复(settleRunRecord completed 路径调用)
|
|
323
|
-
function resetModelFailure(sid, model) { if (model) delete modelFailStreak[modelKey(sid, model)] }
|
|
324
|
-
// ===== 主动模型嗅探:llm.stream 极短直调(无子代理/系统提示/工具,首探 maxTokens=1)=====
|
|
325
|
-
// finish=stop/max-tokens 等均证明模型链路可用;error/aborted/无 finish 才算失败。失败文本复用既有冷却登记,
|
|
326
|
-
// 但不改 badModels 熔断判定。同模型全局只在飞一次,15s 硬超时带 AbortSignal,绝不阻塞 poolCycle 派发。
|
|
327
|
-
// ===== 空响应重试(task-mvrfp1rj)+ 错误码分治(task-mvrqdw4k 用户拍板):探测只裁存活性 =====
|
|
328
|
-
// 实证(cmss/zhanlu 系 glm-5.2、deepseek-v4.1-flash):maxTokens=1 的极简请求被供应商以「terminal stop
|
|
329
|
-
// 零内容块」退回,宿主 adapter 归成 EMPTY_RESPONSE 错误(逐字文案,宿主 checkout grep 实测):
|
|
330
|
-
// ① dsh-llm-pi-ai mapStopReason:model "<m>" returned a completed response with no content
|
|
331
|
-
// ② dsh-llm-deepseek :DeepSeek Messages returned no content
|
|
332
|
-
// 可用性实证(task-mvrqdw4k 三重,决定性):v4.1-flash 正产出当轮对话、两次「失败」皆我方固定 60s 挂死闸
|
|
333
|
-
// 误杀(已修为自适应)——它是慢不是死,纯空响应=供应商怪癖,不该判死。
|
|
334
|
-
// 校准口径(用户拍板·错误码分治矩阵):
|
|
335
|
-
// ① 首探 maxTokens=1(最省 token;一次通即证明链路可用);
|
|
336
|
-
// ② 首探「空响应」→ 自动以 maxTokens=8 重试一次(给模型吐 token 的余地,消掉 length 截断型空响应大头);
|
|
337
|
-
// ③ 重试有内容 → 🟢 探测通过(存活 = 可调用);
|
|
338
|
-
// ④ 重试仍空 → 按文本/码信号分治(classifyEmptyResponse):纯空响应=🟡 degraded 存活(链路通,供应商
|
|
339
|
-
// 怪癖)——observeModel 记降级不记失败、不喂冷却嗅探、路由仍可选用;额度/凭据/连接信号=真错,维持
|
|
340
|
-
// 失败臂照旧 observeModel 失败 + 喂冷却嗅探(quota 文本优先级高于空响应判定,⑬b/⑬d 契约保留);
|
|
341
|
-
// ⑤ 真错误/aborted/超时/无 finish 语义逐字不变,且不触发重试(一次定性,省一次请求也省一次误判)。
|
|
342
|
-
// 放宽不放跑真坏模型(闭环论证):额度真耗尽有两条独立通道——①宿主直接抛 QUOTA_EXCEEDED(探测即降级);
|
|
343
|
-
// ②若网关伪装成空响应,真派发 run 会失败 → recentRuns 软闸(≥2 全败)跳过它——探测管可调性、行为闸管质量。
|
|
344
|
-
// 边界:只动探测判定/健康状态派生/UI 映射与断言;探测调度节奏(probeNextAt/probeIntervalMin)与熔断联动不动。
|
|
345
|
-
var PROBE_MAX_TOKENS = 1 // 首探极简:1 token 证链路通
|
|
346
|
-
var PROBE_MAX_TOKENS_RETRY = 8 // 空响应重试:给模型吐 token 的余地
|
|
347
|
-
var PROBE_ATTEMPT_TIMEOUT_MS = 20000 // 每次尝试独立超时(task-mvsv8zym:两次尝试各拿满 20s,非共享递减——
|
|
348
|
-
// 旧 15s 共享预算下慢模型首探耗 ~10s 后重试只剩残余必超时,被我方预算饿死误记成模型失败)。
|
|
349
|
-
// 总上限 40s 不违反「绝不阻塞 poolCycle」:探测经 maybeProbeModels 后台异步跑(不 await 进派发主链)。
|
|
350
|
-
// EMPTY_RESPONSE 识别(双路):结构化 code 优先(宿主 finish.failure.code,常量见 dsh-llm/lib/types/error.js),
|
|
351
|
-
// 文本特征兜底(兼容只给 message 的旧宿主/网关包装文本)。只认「completed 但零内容」;
|
|
352
|
-
// 429/连接/超时等真错误文本一律 false,走既有失败臂(冷却登记口径不变)。
|
|
353
|
-
function isEmptyResponse(code, text) {
|
|
354
|
-
if (String(code || '') === 'EMPTY_RESPONSE') return true
|
|
355
|
-
return /no content/i.test(String(text || ''))
|
|
356
|
-
}
|
|
357
|
-
// 错误码分治信号(task-mvrqdw4k):宿主把额度/凭据问题单独编码(QUOTA_EXCEEDED_CODE/INVALID_CREDENTIAL_CODE
|
|
358
|
-
// 与 EMPTY_RESPONSE_CODE 并列导出),网关包装文本同样带字样——空响应文本/码命中额度/凭据/连接信号 → 真错,
|
|
359
|
-
// 维持失败臂(冷却嗅探接管);纯空响应(无任何真错信号)→ 🟡degraded 存活(供应商怪癖:链路通但极简请求零内容)。
|
|
360
|
-
var QUOTA_SIGNAL_RE = /429|RATE_LIMIT|QUOTA_EXCEEDED|rate.?limit|限额|额度|quota/i
|
|
361
|
-
var CREDENTIAL_SIGNAL_RE = /INVALID_CREDENTIAL|\b401\b|invalid[ _-]?api[ _-]?key|unauthorized|凭据|凭证/i
|
|
362
|
-
// 空响应分治:返回 'degraded'(纯空响应=链路存活)| 'fail'(额度/凭据/连接信号=真错,走失败臂)。
|
|
363
|
-
// 判定顺序:额度 > 凭据 > 连接 > degraded(与 parseModelCooldown 先限流后连接的保守口径一致)。
|
|
364
|
-
function classifyEmptyResponse(code, text) {
|
|
365
|
-
var s = String(text || ''), c = String(code || '')
|
|
366
|
-
if (QUOTA_SIGNAL_RE.test(s) || /QUOTA/i.test(c)) return 'fail'
|
|
367
|
-
if (CREDENTIAL_SIGNAL_RE.test(s) || /CREDENTIAL/i.test(c)) return 'fail'
|
|
368
|
-
if (CONNECTIVITY_RE.test(s)) return 'fail'
|
|
369
|
-
return 'degraded'
|
|
370
|
-
}
|
|
371
|
-
// 单次探测请求:返回 { status:'ok' } | { status:'empty', text, code } | { status:'fail', text }
|
|
372
|
-
// | { status:'timeout', text }(task-mvsv8zym:超时单列形态,不再与真错误混记 fail——
|
|
373
|
-
// 超时可能只是慢模型+我方预算约束,由上层按首次尝试判定落定或保留既有观测)。
|
|
374
|
-
// attemptMs = 单次尝试独立超时(每尝试各拿满全额预算,非共享递减;task-mvsv8zym)。
|
|
375
|
-
// 每次尝试独立 AbortController/计时器;probeAbort 指向当前在飞尝试(dispose 时统一 abort)。
|
|
376
|
-
async function probeOnce(llm, model, maxTokens, attemptMs) {
|
|
377
|
-
var ac = new AbortController(), timedOut = false, disposeTimer = null
|
|
378
|
-
var attempt = Math.max(1, Number(attemptMs) || 0)
|
|
379
|
-
var slash = model.indexOf('/')
|
|
380
|
-
try {
|
|
381
|
-
if (ctx.timer && typeof ctx.timer.timeout === 'function') disposeTimer = ctx.timer.timeout(function () { timedOut = true; ac.abort() }, attempt)
|
|
382
|
-
else { var nativeTimer = setTimeout(function () { timedOut = true; ac.abort() }, attempt); disposeTimer = function () { clearTimeout(nativeTimer) } }
|
|
383
|
-
probeAbort[model] = ac
|
|
384
|
-
var stream = llm.stream({ provider: model.slice(0, slash), model: model.slice(slash + 1), messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], maxTokens: maxTokens, signal: ac.signal })
|
|
385
|
-
var finished = false, failure = '', failureCode = ''
|
|
386
|
-
for await (var chunk of stream) {
|
|
387
|
-
if (!chunk || chunk.type !== 'finish') continue
|
|
388
|
-
finished = true
|
|
389
|
-
var reason = chunk.reason || {}
|
|
390
|
-
if (reason.kind === 'error' || reason.kind === 'aborted') {
|
|
391
|
-
failure = String((reason.failure && reason.failure.message) || reason.kind || '模型探测失败')
|
|
392
|
-
failureCode = String((reason.failure && reason.failure.code) || '')
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
if (timedOut) return { status: 'timeout', text: 'probe timeout ' + Math.round(attempt) + 'ms' }
|
|
396
|
-
if (!finished) return { status: 'fail', text: '模型探测流未返回 finish' }
|
|
397
|
-
if (failure) return isEmptyResponse(failureCode, failure) ? { status: 'empty', text: failure, code: failureCode } : { status: 'fail', text: failure }
|
|
398
|
-
return { status: 'ok' }
|
|
399
|
-
} catch (e) {
|
|
400
|
-
// 抛错形态(provider 直接 throw LlmError):code 可能带 EMPTY_RESPONSE,文本兜底同一谓词
|
|
401
|
-
if (timedOut) return { status: 'timeout', text: 'probe timeout ' + Math.round(attempt) + 'ms' }
|
|
402
|
-
var text = String(e && e.message || e)
|
|
403
|
-
return isEmptyResponse(e && e.code, text) ? { status: 'empty', text: text, code: String(e && e.code || '') } : { status: 'fail', text: text }
|
|
404
|
-
} finally {
|
|
405
|
-
try { if (typeof disposeTimer === 'function') disposeTimer() } catch (_) {}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
async function probeModel(sid, model) {
|
|
409
|
-
if (probeDisposed) return null
|
|
410
|
-
if (probeInFlight[model]) return probeInFlight[model]
|
|
411
|
-
var llm = ctx.get && ctx.get('llm')
|
|
412
|
-
if (!llm || typeof llm.stream !== 'function') return null
|
|
413
|
-
var slash = model.indexOf('/')
|
|
414
|
-
if (slash <= 0 || slash === model.length - 1) return null
|
|
415
|
-
var started = Date.now()
|
|
416
|
-
var promise = (async function () {
|
|
417
|
-
try {
|
|
418
|
-
var outcome = await probeOnce(llm, model, PROBE_MAX_TOKENS, PROBE_ATTEMPT_TIMEOUT_MS)
|
|
419
|
-
if (outcome.status === 'timeout') {
|
|
420
|
-
// ③ 首探即超时(task-mvsv8zym):无重试(⑬e 契约保留),且不覆盖既有观测——只更新 note
|
|
421
|
-
// (判定保留):超时可能只是慢模型+我方预算约束,已 healthy/degraded 条目不倒退成 unknown。
|
|
422
|
-
observeModel(sid, model, false, { probe: true, preserveObserved: true, note: String(outcome.text || '').slice(0, 300) })
|
|
423
|
-
return false
|
|
424
|
-
}
|
|
425
|
-
if (outcome.status === 'empty') {
|
|
426
|
-
// 空响应 = 唯一触发重试的形态(真错误/超时/无 finish 一次定性,不重试)
|
|
427
|
-
if (probeDisposed) {
|
|
428
|
-
// 探测已停(dispose 中断):无重试机会,仍按错误码分治落定(停机是我方调度约束,非模型健康信号)
|
|
429
|
-
var budgetText = '空响应且探测已停(不重试,零内容):' + outcome.text
|
|
430
|
-
outcome = classifyEmptyResponse(outcome.code, outcome.text) === 'degraded'
|
|
431
|
-
? { status: 'degraded', text: budgetText }
|
|
432
|
-
: { status: 'fail', text: budgetText }
|
|
433
|
-
} else {
|
|
434
|
-
var first = outcome
|
|
435
|
-
outcome = await probeOnce(llm, model, PROBE_MAX_TOKENS_RETRY, PROBE_ATTEMPT_TIMEOUT_MS)
|
|
436
|
-
// 重试仍空 = 按错误码分治(task-mvrqdw4k 用户拍板):纯空响应=🟡degraded 存活(供应商怪癖,链路通)
|
|
437
|
-
// ——不喂冷却、路由仍可选用;额度/凭据/连接信号=真错,维持失败臂(quota 文本优先级高于空响应判定,
|
|
438
|
-
// ⑬b/⑬d 契约保留)。文案标明重试口径便于排查与嗅探归因。
|
|
439
|
-
if (outcome.status === 'empty') {
|
|
440
|
-
var emptyText = 'maxTokens=' + PROBE_MAX_TOKENS_RETRY + ' 重试仍空(EMPTY_RESPONSE):' + outcome.text
|
|
441
|
-
outcome = classifyEmptyResponse(outcome.code, outcome.text) === 'degraded'
|
|
442
|
-
? { status: 'degraded', text: emptyText }
|
|
443
|
-
: { status: 'fail', text: emptyText }
|
|
444
|
-
} else if (outcome.status === 'timeout') {
|
|
445
|
-
// ② 重试超时≠失败(task-mvsv8zym):每尝试独立 20s 预算仍耗尽=慢模型,沿用首次尝试判定落定
|
|
446
|
-
// ——与「探测已停」分支同原则(我方预算/停机约束非模型健康信号,L407-412 对称补齐):
|
|
447
|
-
// 首探纯空响应→🟡degraded;首探文本带额度/凭据/连接信号→fail(嗅探接管)。
|
|
448
|
-
var firstVerdict = classifyEmptyResponse(first.code, first.text)
|
|
449
|
-
outcome = firstVerdict === 'degraded'
|
|
450
|
-
? { status: 'degraded', text: '重试预算耗尽(慢模型),沿用首探判定:' + String(first.text || '') }
|
|
451
|
-
: { status: 'fail', text: '重试预算耗尽(慢模型),沿用首探判定:' + String(first.text || '') }
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
if (outcome.status === 'ok') {
|
|
456
|
-
observeModel(sid, model, true, { probe: true, latencyMs: Date.now() - started })
|
|
457
|
-
return true
|
|
458
|
-
}
|
|
459
|
-
if (outcome.status === 'degraded') {
|
|
460
|
-
// 🟡 降级存活臂(task-mvrqdw4k):记存活不记失败——observeModel 落 degraded 派生态;
|
|
461
|
-
// 不调 sniffModelCooldown(冷却表零登记);recentRuns 不动(探测本就不进 recentRuns,观察臂已隔开)。
|
|
462
|
-
observeModel(sid, model, false, { probe: true, degraded: true, latencyMs: Date.now() - started, note: String(outcome.text || '').slice(0, 300) })
|
|
463
|
-
return 'degraded'
|
|
464
|
-
}
|
|
465
|
-
var text = String(outcome.text || '模型探测失败').slice(0, 300)
|
|
466
|
-
observeModel(sid, model, false, { probe: true, latencyMs: Date.now() - started, note: text })
|
|
467
|
-
if (!probeDisposed) { try { await sniffModelCooldown(sid, '', model, text) } catch (_) {} }
|
|
468
|
-
return false
|
|
469
|
-
} catch (e) {
|
|
470
|
-
// 兜底臂(probeOnce 自吞错,理论不可达):语义与旧实现逐字一致
|
|
471
|
-
var failText = String(e && e.message || e).slice(0, 300)
|
|
472
|
-
observeModel(sid, model, false, { probe: true, latencyMs: Date.now() - started, note: failText })
|
|
473
|
-
if (!probeDisposed) { try { await sniffModelCooldown(sid, '', model, failText) } catch (_) {} }
|
|
474
|
-
return false
|
|
475
|
-
} finally {
|
|
476
|
-
delete probeInFlight[model]
|
|
477
|
-
delete probeAbort[model]
|
|
478
|
-
}
|
|
479
|
-
})()
|
|
480
|
-
probeInFlight[model] = promise
|
|
481
|
-
return promise
|
|
482
|
-
}
|
|
483
|
-
async function probeConfiguredModels(sid, d) {
|
|
484
|
-
var c = cfg(d), models = core.configuredModelIds(d)
|
|
485
|
-
for (var i = 0; i < models.length; i++) syncModelHealth(sid, models[i])
|
|
486
|
-
if (!c.probeEnabled) return { disabled: true, models: models, probed: 0, degraded: 0 }
|
|
487
|
-
var llm = ctx.get && ctx.get('llm')
|
|
488
|
-
if (!llm || typeof llm.stream !== 'function') return { disabled: false, unavailable: true, models: models, probed: 0, degraded: 0 }
|
|
489
|
-
var probed = 0, healthy = 0, degraded = 0
|
|
490
|
-
for (var m = 0; m < models.length; m++) {
|
|
491
|
-
if (probeDisposed) break
|
|
492
|
-
var before = probeInFlight[models[m]] ? false : true
|
|
493
|
-
var outcome = await probeModel(sid, models[m])
|
|
494
|
-
if (outcome === null) continue
|
|
495
|
-
if (before) probed++
|
|
496
|
-
// task-mvrqdw4k:probeModel 三态消费——true=🟢存活、'degraded'=🟡降级存活(供应商怪癖)、false=失败
|
|
497
|
-
if (outcome === true) healthy++
|
|
498
|
-
else if (outcome === 'degraded') degraded++
|
|
499
|
-
}
|
|
500
|
-
return { disabled: false, models: models, probed: probed, healthy: healthy, degraded: degraded }
|
|
501
|
-
}
|
|
502
|
-
// 15s 调度心跳驱动间隔闸:配置变化立即重排,探测在后台跑,不 await 进派发主链。
|
|
503
|
-
function maybeProbeModels(sid, d) {
|
|
504
|
-
var c = cfg(d), now = Date.now()
|
|
505
|
-
if (!c.probeEnabled) { probeNextAt[sid] = 0; return }
|
|
506
|
-
var llm = ctx.get && ctx.get('llm')
|
|
507
|
-
if (!llm || typeof llm.stream !== 'function') return
|
|
508
|
-
var planKey = String(c.probeIntervalMin)
|
|
509
|
-
if (probeNextAt[sid] && probeNextAt[sid].plan === planKey && now < probeNextAt[sid].at) return
|
|
510
|
-
if (!probeNextAt[sid] || probeNextAt[sid].plan !== planKey) probeNextAt[sid] = { at: 0, plan: planKey }
|
|
511
|
-
probeNextAt[sid] = { at: now + c.probeIntervalMin * 60000, plan: planKey }
|
|
512
|
-
probeConfiguredModels(sid, d).catch(function (e) { console.error('[task-board] 模型健康探测失败(下周期重试):', String(e)) })
|
|
513
|
-
}
|
|
60
|
+
// 冷却表/健康表/熔断连击表(modelCooldown/modelHealth/modelFailStreak 惰性初始化,原「缺字段就地补」
|
|
61
|
+
// 同款)已拆至 lib/model-health.mjs(拆分第 2 刀 task-mvt8c1yx-2 + 第 3 刀 task-mvt8c1yx-3):工厂内
|
|
62
|
+
// 就地补缺字段(state 共享同一引用,语义逐字不变),见下方 health 实例。
|
|
63
|
+
// 探测态(probeInFlight/probeAbort/probeNextAt/probeDisposed)已随第 4 刀(task-mvt8c1yx-4)迁入
|
|
64
|
+
// lib/model-health.mjs 工厂内部——卸载入口收敛为 health.disposeProbe()(下方卸载 effect 回调只调它)。
|
|
65
|
+
// 健康域实例(拆分第 2 刀 task-mvt8c1yx-2 + 第 3 刀 task-mvt8c1yx-3 + 第 4 刀 task-mvt8c1yx-4):健康表/
|
|
66
|
+
// 冷却登记/冷却期判定/模型目录/备用链健康判定/熔断连击/主动探测(probeOnce/probeModel/probeConfiguredModels/
|
|
67
|
+
// maybeProbeModels/disposeProbe)函数集已拆至 lib/model-health.mjs(state 共享同一引用——
|
|
68
|
+
// modelHealth/modelCooldown/badModels/modelFailStreak 语义逐字不变;各表惰性初始化在工厂内就地补)。
|
|
69
|
+
// 卸载闸随第 4 刀收敛进工厂(probeDisposed 成为其内部状态,disposeProbe() 为唯一卸载入口)——本侧不再
|
|
70
|
+
// 注入 disposed 读取器;ctx/pushSysNote 随第 3 刀注入(modelDirectory 的 llm 目录获取 / feedModelFailure
|
|
71
|
+
// 的系统通知 / 探测域的 llm.stream 直调与 timer)。
|
|
72
|
+
var health = createModelHealth({ state: state, mutateLocked: mutateLocked, ctx: ctx, pushSysNote: pushSysNote })
|
|
73
|
+
// 整列别名(拆分第 2/3/4 刀):既有调用点文本不动(降低 diff 风险),函数集统一来自 health 工厂实例。
|
|
74
|
+
// markModelCircuit/isModelKillFailure/feedModelFailure 随第 6 刀由 settle.mjs 经 opts.health 实例解构
|
|
75
|
+
// (角色结算搬移,本文件仅 spawnOneShot 的 sniffModelCooldown 回退臂仍消费);sniffModelCooldown 保留。
|
|
76
|
+
const { healthBucket, healthEntry, syncModelHealth, cooldownLive, sniffModelCooldown, modelDirectory, outOfDir, traceStaleModel, healthyModel, pickHealthyFallback, probeConfiguredModels, maybeProbeModels, disposeProbe } = health
|
|
77
|
+
// 结算主干实例(拆分第 5 刀;拆分第 6 刀 task-mvt8c1yx-6):四个角色结算已搬入 settle.mjs
|
|
78
|
+
// (settleRun 分路模块内直调,roles 懒注入撤掉),工厂入参补通知类 deps(未接线 → 静默跳过的
|
|
79
|
+
// 守卫写法随行)——spawnOneShot 的 activation 拆除臂两调用点改重指 settle.settleWorker(下方)。
|
|
80
|
+
var settle = createSettle({
|
|
81
|
+
state: state, ctx: ctx, runsFor: runsFor, mutateLocked: mutateLocked,
|
|
82
|
+
rootForSession: rootForSession, health: health, rt: rt, sessionsRoot: sessionsRoot,
|
|
83
|
+
closeRunHistory: closeRunHistory, resumeTarget: resumeTarget,
|
|
84
|
+
poolHealthFor: poolHealthFor, withTimeout: withTimeout, armTimeouts: armTimeouts, logSizeOf: logSizeOf,
|
|
85
|
+
pushSysNote: pushSysNote, maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone,
|
|
86
|
+
notifyLateReject: notifyLateReject, notifyLateApprove: notifyLateApprove,
|
|
87
|
+
notifyDecomposed: notifyDecomposed, kickCycle: kickCycle
|
|
88
|
+
})
|
|
89
|
+
// 主动探测域(拆分第 4 刀 task-mvt8c1yx-4 全量迁入 lib/model-health.mjs):PROBE_MAX_TOKENS/
|
|
90
|
+
// PROBE_MAX_TOKENS_RETRY/PROBE_ATTEMPT_TIMEOUT_MS/QUOTA_SIGNAL_RE/CREDENTIAL_SIGNAL_RE 常量与
|
|
91
|
+
// task-mvrfp1rj/task-mvrqdw4k/task-mvsv8zym 校准注释、isEmptyResponse/classifyEmptyResponse 分治
|
|
92
|
+
// 纯函数、probeOnce/probeModel/probeConfiguredModels/maybeProbeModels 探测编排、探测态
|
|
93
|
+
// (probeInFlight/probeAbort/probeNextAt/probeDisposed)与统一卸载入口 disposeProbe 均已随迁
|
|
94
|
+
// (cfg 由 core 直接 import、ctx 经工厂注入)——本文件保留两个接线点:poolCycle 的 maybeProbeModels
|
|
95
|
+
// 间隔闸调用(下方)与插件卸载 effect 的 disposeProbe 回调(文件尾)。
|
|
514
96
|
// 「上下文注入」区块通道的按子代理会话缓存表 + 首轮竞速认领队列随该通道一起退役(task-muvjs392):
|
|
515
97
|
// 瘦身清单直接进首条 prompt,不再需要注入段命中与父子归属认领(并行 spawn 认领错包的潜伏 bug 一并消灭)。
|
|
516
98
|
var knownSessions = state.knownSessions
|
|
@@ -540,7 +122,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
540
122
|
// activeRuns / dispatchedEver 容器本体在 state(index.mjs 统一构建);runsFor/isDispatched 在 session 层
|
|
541
123
|
|
|
542
124
|
// 模型熔断:带覆盖模型的 run 若立即失败(如 UNKNOWN_MODEL——模型在当前网关没配置),记入坏名单回退父级
|
|
543
|
-
|
|
125
|
+
// modelKey(sid|model 复合键)已拆至 lib/model-health.mjs(拆分第 2 刀 task-mvt8c1yx-2,单源导出——
|
|
126
|
+
// 熔断名单与健康表熔断判定共用同一键式,上方 import 同源使用)。
|
|
544
127
|
|
|
545
128
|
var _cachedProvider = null
|
|
546
129
|
function pickProvider() {
|
|
@@ -1024,10 +607,11 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1024
607
|
// (正常完成 parked-idle 不落定、不误伤续跑)——把它接上就是失败/拆除场景的零事件依赖结算通道。
|
|
1025
608
|
// rec.settled 首胜旗与事件/超时/上报三通道共用:先到先结,后到被挡。
|
|
1026
609
|
// 归属不变式(task-mvnyk55n 驳回根修):「rec 被摘出 runsFor 活跃表 ⇒ 摘除点已同步立
|
|
1027
|
-
// rec.settled
|
|
1028
|
-
//
|
|
610
|
+
// rec.settled」对全部摘除路径成立(落闸弃权臂 / 续跑弃权臂 / settleRun 的 finish 臂与事件臂
|
|
611
|
+
// (lib/settle.mjs)/ settleReportedRun(lib/settle.mjs)/ 幽灵清扫 / doTerminate 摘表即立——
|
|
1029
612
|
// 后者正是本次补上的缺口:hold 终止的 dispose 落定 result 时本臂若抢结,会把手动终止
|
|
1030
613
|
// 误记成 Worker 失败)。因此本臂的 settled 判即归属守卫,无需再判表项在否。
|
|
614
|
+
// (各臂按函数名定位;本文件历次拆分后行号持续漂移,此注释不再钉行号。)
|
|
1031
615
|
if (useContinuable && actForSettle && actForSettle.result && typeof actForSettle.result.then === 'function') {
|
|
1032
616
|
actForSettle.result.then(function (res) {
|
|
1033
617
|
if (rec.settled) return
|
|
@@ -1036,11 +620,12 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1036
620
|
// diagnostic 截断放宽 120→400(task-mvneq4kk):glm 等供应商的 429 报错「限额将在 YYYY-MM-DD HH:mm:ss
|
|
1037
621
|
// 重置」结构化文本常超 120 字符,截太短会把重置时间切掉 → RATE_LIMIT 嗅探只能退 30min 缺省。
|
|
1038
622
|
var errText = '子会话被宿主拆除(stopReason: ' + stopReason + (res && res.diagnostic ? ',' + String(res.diagnostic).slice(0, 400) : '') + ')'
|
|
1039
|
-
settleWorker
|
|
623
|
+
// settleWorker 调用点重指 settle.settleWorker(拆分第 6 刀:函数已搬入 settle.mjs,从工厂返回集导出)
|
|
624
|
+
settle.settleWorker(sid, rec, '', true, errText).catch(function (e) { console.error('[task-board] activation 拆除结算失败 (task ' + rec.taskId + '):', String(e)) })
|
|
1040
625
|
}, function (err) {
|
|
1041
626
|
if (rec.settled) return
|
|
1042
627
|
rec.settled = true
|
|
1043
|
-
settleWorker(sid, rec, '', true, '子会话 activation 异常:' + String(err && err.message || err).slice(0, 200)).catch(function (e) { console.error('[task-board] activation 异常结算失败 (task ' + rec.taskId + '):', String(e)) })
|
|
628
|
+
settle.settleWorker(sid, rec, '', true, '子会话 activation 异常:' + String(err && err.message || err).slice(0, 200)).catch(function (e) { console.error('[task-board] activation 异常结算失败 (task ' + rec.taskId + '):', String(e)) })
|
|
1044
629
|
})
|
|
1045
630
|
}
|
|
1046
631
|
// ⚠️ spawn 返回 → 登记 rec 必须**零 await**(既有纪律):continuable 子会话的 running→idle 事件
|
|
@@ -1114,7 +699,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1114
699
|
var startedAt = rec.startedAt
|
|
1115
700
|
var softMs = c.softTimeoutMin * 60000
|
|
1116
701
|
var hardMs = c.hardTimeoutMin * 60000
|
|
1117
|
-
function finish(res, err) { if (rec.settled) return; rec.settled = true; settleRun(sid, rec, res, err) }
|
|
702
|
+
function finish(res, err) { if (rec.settled) return; rec.settled = true; settle.settleRun(sid, rec, res, err) }
|
|
1118
703
|
function disarmTimeouts() {
|
|
1119
704
|
if (rec.timersDisarmed) return
|
|
1120
705
|
rec.timersDisarmed = true
|
|
@@ -1336,7 +921,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1336
921
|
}
|
|
1337
922
|
rec.settled = true // 先立旗:堵迟到的 idle/超时臂重入(与事件臂同一纪律)
|
|
1338
923
|
console.error('[task-board] 续跑会话已不存在(sendMessage 后活跃目录未命中,死会话哨兵),立即失败重派 task ' + rec.taskId + ' child ' + rec.childId)
|
|
1339
|
-
settleRun(sid, rec, null, new Error('续跑会话已不存在(sendMessage 后未在活跃目录命中,死会话哨兵)'))
|
|
924
|
+
settle.settleRun(sid, rec, null, new Error('续跑会话已不存在(sendMessage 后未在活跃目录命中,死会话哨兵)'))
|
|
1340
925
|
}
|
|
1341
926
|
var q
|
|
1342
927
|
try { q = subagents.listChildren(sid) } catch (e) { return }
|
|
@@ -1348,1068 +933,38 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1348
933
|
rec.deathDispose = function () { try { if (typeof h === 'function') h() } catch (_) {} }
|
|
1349
934
|
}
|
|
1350
935
|
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
// 无论从哪条入口进来(事件通道 settleContinuable / 上报通道 board_report),run 记录的收尾动作
|
|
1354
|
-
// 必须一致:①关 run 结局(closeRunHistory)②usage 落账(seq 水位线增量)③摘除软/硬超时臂。
|
|
1355
|
-
// 三条各自幂等:重复结算(idle 事件重入 / 上报与事件双通道先后到达)不二次关账、不二次记账。
|
|
1356
|
-
// ===== 结算≠停止根修(task-mv8wuznb,ontology 实证 2026-10-15:卡 02:02 已 resolved、verdict 已落账,
|
|
1357
|
-
// verifier 子会话却持续写盘空转到 10:16 被主窗口手杀——8 小时空烧)=====
|
|
1358
|
-
// 病根:一次性 run 的 settle 只关账(dispose 只释放本地 run 句柄,不停子会话的 agent loop)——
|
|
1359
|
-
// verifier 调完 board_verdict 后 turn 未结束(继续探索/循环),会话持续空转烧 token。
|
|
1360
|
-
// 一次性子代理「结算即销毁」是设计语义,这里补齐销毁的半步:结算落定后对该 run 的子会话补一枪
|
|
1361
|
-
// interrupt(与 endContinuable 同款 authority 形状,fire-and-return 同步发信号即返回)。
|
|
1362
|
-
// 挂载点取 settleRunRecord 末尾 = 所有一次性落定路径的唯一汇合点(settleRun 的
|
|
1363
|
-
// completed/failed/incomplete、幽灵回收 reapGhostRecs 的收尾都经这里)——含事故原形
|
|
1364
|
-
// 「board_verdict 落账 → 卡 resolved → rec 下轮被幽灵回收」那条(rec 在表外时 settleRun 根本跑不到)。
|
|
1365
|
-
// 边界(零改动):hook 点位不在本卡范围;重复 interrupt 无害(取消信号幂等)。continuable 豁免经
|
|
1366
|
-
// task-mvbeallq 收紧:原「continuable 即跳」过宽(无续跑位的残影借豁免续跑),现仅「续跑位真在
|
|
1367
|
-
// 本 child 手上」才放行,其余 continuable 落定同样补停——收紧细则与竞态论证见函数头注释。
|
|
1368
|
-
// 失败只 console.error:结算已落定,残留空转只影响 token 不影响正确性(与 terminate 的 killFail
|
|
1369
|
-
// 告警分级不同——那是用户显式要杀,这里是收尾补强)。
|
|
1370
|
-
async function stopSettledOneShot(sid, rec) {
|
|
1371
|
-
if (!rec || !rec.id) return
|
|
1372
|
-
if (rec.role !== 'verifier' && rec.role !== 'worker') return // 只对 verifier 与 worker 动手
|
|
1373
|
-
var subagents = ctx.subagents
|
|
1374
|
-
if (!subagents || typeof subagents.interrupt !== 'function') return // 老宿主/老测试桩无此能力:逐字不变
|
|
1375
|
-
var parent = rootForSession(sid)
|
|
1376
|
-
if (!parent) return // 无活 root 可授权(root 已死/重建窗口)→ 跳过,结算语义不受影响
|
|
1377
|
-
// ===== continuable 豁免收紧(task-mvbeallq,残影治理①)=====
|
|
1378
|
-
// 原闸门「rec.continuable 即跳」过宽:continuable 但**已无续跑位**的落定——completed(resumeTarget
|
|
1379
|
-
// 只认超时/失败结局)/ noResume 落旗(reap 失败)/ 卡面最新 worker 留档已被更新的 run 取代
|
|
1380
|
-
// (池故障后主窗口重派了新 Worker)——借豁免续跑就是残影(ontology 实证:旧子会话残影续跑 5 分钟
|
|
1381
|
-
// 逐文件推进,与新 Worker 差点同批文件互踩)。interrupt 对 continuable 只打断 turn、不销毁会话
|
|
1382
|
-
// (endContinuable 同款语义),补停安全。豁免只剩「续跑位真在本 child 手上」两种形态:
|
|
1383
|
-
// ① 本卡活跃表已有接管**同一子会话**的新 rec——续跑在途,此刻打断会杀掉刚复活的新 turn。
|
|
1384
|
-
// (续跑路径纪律:sendMessage 返回后**零 await**先登记 rec 再追加续跑留档,故新 turn 全程
|
|
1385
|
-
// 被本形态覆盖;不同 id 的新 rec=fresh spawn 新 Worker,不豁免——旧残影照停。)
|
|
1386
|
-
// ② 锁外新鲜快照上 resumeTarget 仍指向本 child——续跑位未被消费,下轮重派会冷复活它。
|
|
1387
|
-
// (豁免判定必须读新鲜快照:结算刚关账,只有自己内存里的 rec 不足以判定留档次序——
|
|
1388
|
-
// 幽灵回收路径上卡面可能已有更新的 worker 留档。)
|
|
1389
|
-
// 两形态之外一律补停;快照读不到(t=null)按「无续跑位」处理——宁停勿留残影。
|
|
1390
|
-
if (rec.continuable) {
|
|
1391
|
-
var cur = runsFor(sid)[rec.taskId]
|
|
1392
|
-
if (cur && String(cur.id) === String(rec.id)) return // ① 续跑在途(新 rec 接管同一子会话)
|
|
1393
|
-
var t = null
|
|
1394
|
-
try {
|
|
1395
|
-
var dsnap = await rt(sid)
|
|
1396
|
-
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 } } }
|
|
1397
|
-
} catch (_) { t = null }
|
|
1398
|
-
if (t && resumeTarget(t) === String(rec.id)) return // ② 续跑位在手:留存会话供冷复活
|
|
1399
|
-
}
|
|
1400
|
-
try {
|
|
1401
|
-
subagents.interrupt(String(rec.id), { kind: 'ancestor', agent: parent })
|
|
1402
|
-
} catch (e) {
|
|
1403
|
-
console.error('[task-board] 一次性 run 结算后 interrupt 子会话失败(会话可能仍在空转,只影响 token)task ' + rec.taskId + ' child ' + rec.id + ':', String(e))
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
async function settleRunRecord(sid, rec, opts) {
|
|
1408
|
-
// ① 结局落卡:failed → 有 err 记 timeout/error,无 err 记 incomplete(与一次性路径口径一致);
|
|
1409
|
-
// 成功 → completed。noResume 旗随 rec.resumeBlocked(interrupt 失败)落卡。
|
|
1410
|
-
try {
|
|
1411
|
-
await closeRunHistory(sid, rec.taskId, String(rec.id), opts.outcome, rec.continuable ? { noResume: rec.resumeBlocked === true } : null)
|
|
1412
|
-
} catch (e) { console.error('[task-board] closeRunHistory 收尾失败 (task ' + rec.taskId + '):', String(e)) }
|
|
1413
|
-
// 统一健康表被动面:每个可归因 run 落定都记最近 5 次成败与本次延迟;父模型 id 不可知则跳过。
|
|
1414
|
-
if (rec.model) observeModel(sid, rec.model, opts.outcome === 'completed', { latencyMs: Math.max(0, Date.now() - Number(rec.startedAt || Date.now())) })
|
|
1415
|
-
// 熔断连击归零(task-mvowb2hc 洞①):连击只在「连续」语义内计数——任一可归因 run 正常完成
|
|
1416
|
-
// 即证明模型链路恢复,清零重新计数(失败落定不动连击,也归不了零)。
|
|
1417
|
-
if (rec.model && opts.outcome === 'completed') resetModelFailure(sid, rec.model)
|
|
1418
|
-
// ② usage 落账:读该次 run 的 v4 日志聚合(水位线增量),累加到任务。放在状态推进之后:
|
|
1419
|
-
// 统计是附加信息,读日志失败/无 usage 时静默跳过,绝不影响结算语义。
|
|
1420
|
-
// Worker 失败重试、驳回重做都会各走一次 settleRun,因此多轮消耗天然累加(runs 计数)。
|
|
1421
|
-
try { await accumulateRunUsage(sid, rec) } catch (e) { console.error('[task-board] usage 收尾失败 (task ' + rec.taskId + '):', String(e)) }
|
|
1422
|
-
// ③ 摘除两级超时臂:软臂不再刷假告警,硬臂不再对已结算的闲置会话补一枪 interrupt。
|
|
1423
|
-
try { if (typeof rec.disarm === 'function') rec.disarm() } catch (_) {}
|
|
1424
|
-
// ② settle 通道心跳(运行时健康自检):三件套各自幂等且各自 catch 永不抛,
|
|
1425
|
-
// 走到这里即「一次成功结算活动」——rpc 侧据此判定「结算通道 >30min 无活动」黄条。
|
|
1426
|
-
poolHealthFor(sid).settleLastOkAt = Date.now()
|
|
1427
|
-
// ④ 结算≠停止根修(task-mv8wuznb):落定后主动 interrupt 子会话,堵「结算≠停止」空转洞。
|
|
1428
|
-
// task-mvbeallq 起改 async(continuable 豁免收紧需读锁外快照算 resumeTarget)——interrupt 本身
|
|
1429
|
-
// 仍是同步信号 fire-and-return;闸门与 try/catch 全在函数内/调用处,绝不阻断/改变结算语义;
|
|
1430
|
-
// 续跑位在手的 continuable 与 hook 在函数内被原样放行(豁免边界见函数头注释)。
|
|
1431
|
-
try { await stopSettledOneShot(sid, rec) } catch (e) { console.error('[task-board] 结算后补停子会话异常(不影响结算)task ' + rec.taskId + ':', String(e)) }
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
// ===== 拆解官结算(task-mvnenrwk):host 读 result.structured 物化 draft 子卡 =====
|
|
1435
|
-
// 三分支:verdict=split → applyDecomposeProposal 物化(幂等清旧批 draft 提案)+ 源卡摘要消息 + 通知;
|
|
1436
|
-
// verdict=keep → 源卡记「无需拆分:rationale」;
|
|
1437
|
-
// run 失败 / structured 缺失 / 提案畸形 → 源卡记失败原因 + 通知(源卡与旧提案批都不动)。
|
|
1438
|
-
// 通知(task-mvrqiu6x):split/keep 成功改走 notifyDecomposed(pushReceipt 聚合内核出「🧩 已拆解」分区,
|
|
1439
|
-
// 受 notifyDone 开关约束)——源卡按设计停在 draft/pending,旧实现借 pushSysNote 系统通道绕行
|
|
1440
|
-
// 「带 taskId 会被仍在执行中过滤器丢弃」的墙,代价是成功信号伪装成「⚠️ 系统异常」;
|
|
1441
|
-
// 失败分支保持 pushSysNote 🚨 不变(异常就该是异常)。
|
|
1442
|
-
async function settleDecompose(sid, rec, res, err) {
|
|
1443
|
-
var failed = !!err || (res && res.stopReason && res.stopReason !== 'completed')
|
|
1444
|
-
var errText = err ? String(err) : (res && (res.diagnostic || res.stopReason) || '')
|
|
1445
|
-
var structured = (res && res.structured && typeof res.structured === 'object') ? res.structured : null
|
|
1446
|
-
var applied = null
|
|
1447
|
-
var srcTitle = ''
|
|
1448
|
-
// 完成回执开关(设置区「通知」notifyDone,task-mvrqiu6x):同一次持锁回调里读看板文档(零额外读盘,
|
|
1449
|
-
// 与 settleWorker/settleVerifier 写法一致);缺字段 → cfg 归一为 true,宁可多报不漏报。
|
|
1450
|
-
var doneOn = true
|
|
1451
|
-
try {
|
|
1452
|
-
applied = await mutateLocked(sid, function (d) {
|
|
1453
|
-
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
1454
|
-
if (!t) return null
|
|
1455
|
-
srcTitle = t.title
|
|
1456
|
-
doneOn = cfg(d).notifyDone !== false
|
|
1457
|
-
if (!Array.isArray(t.messages)) t.messages = []
|
|
1458
|
-
if (failed) {
|
|
1459
|
-
t.messages.push({ kind: 'decompose', text: '🧩 拆解官 run 失败(' + String(errText || '未知错误').slice(0, 200) + ')——源卡未改动,可重试 task_decompose', at: new Date().toISOString(), by: 'system' })
|
|
1460
|
-
return { ok: false, failed: true }
|
|
1461
|
-
}
|
|
1462
|
-
if (!structured) {
|
|
1463
|
-
t.messages.push({ kind: 'decompose', text: '🧩 拆解官未返回结构化提案(structured 缺失,可能宿主/provider 不支持 outputSchema)——源卡未改动,可重试 task_decompose', at: new Date().toISOString(), by: 'system' })
|
|
1464
|
-
return { ok: false, failed: true }
|
|
1465
|
-
}
|
|
1466
|
-
return applyDecomposeProposal(d, t, structured, new Date().toISOString())
|
|
1467
|
-
}, true) // skipKick:物化的是 draft 子卡(不参与派发),无需踢 poolCycle
|
|
1468
|
-
} catch (e) { console.error('[task-board] settle decompose failed (task ' + rec.taskId + '):', String(e)); applied = null }
|
|
1469
|
-
try {
|
|
1470
|
-
// 拆解官正式回执:走 notifyDecomposed(「🧩 已拆解」聚合分区,notifyDone 闸门同完成回执口径;
|
|
1471
|
-
// 源卡 messages 的「🧩 拆解提案已就位/无需拆分」留痕已由 applyDecomposeProposal 落卡,
|
|
1472
|
-
// 关开关时留痕照旧不丢)——成功信号不再借系统通道伪装异常。
|
|
1473
|
-
if (applied && applied.ok && applied.verdict === 'split') {
|
|
1474
|
-
if (doneOn && typeof notifyDecomposed === 'function') notifyDecomposed(sid, { sourceId: rec.taskId, sourceTitle: srcTitle, verdict: 'split', children: applied.children, reason: '' })
|
|
1475
|
-
} else if (applied && applied.ok && applied.verdict === 'keep') {
|
|
1476
|
-
if (doneOn && typeof notifyDecomposed === 'function') notifyDecomposed(sid, { sourceId: rec.taskId, sourceTitle: srcTitle, verdict: 'keep', children: [], reason: structured ? String(structured.rationale == null ? '' : structured.rationale).trim().slice(0, 150) : '' })
|
|
1477
|
-
} else pushSysNote(sid, '🚨 拆解官 run 未产出可用提案(源卡 ' + rec.taskId + '):' + String((applied && applied.error) || errText || 'structured 缺失').slice(0, 200)) // 失败分支:异常就是异常,保持 🚨 系统通道(文案逐字不变)
|
|
1478
|
-
} catch (e) { console.error('[task-board] decompose notify failed:', String(e)) }
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
async function settleRun(sid, rec, res, err) {
|
|
1482
|
-
if (runsFor(sid)[rec.taskId] !== rec) return // 已被 terminate 等路径处理
|
|
1483
|
-
delete runsFor(sid)[rec.taskId]
|
|
1484
|
-
// 收尾分路(卡2):一次性 run 走 dispose(既有语义逐字不变);continuable 走 interrupt——
|
|
1485
|
-
// **只打断当前 turn,不销毁子会话**(Activation/未认领收件箱/已发布后代全部保留),
|
|
1486
|
-
// idle 后仍可被 sendMessage 冷复活,这正是重派续跑的前提。
|
|
1487
|
-
try {
|
|
1488
|
-
if (rec.continuable) await endContinuable(sid, rec)
|
|
1489
|
-
else await rec.run.dispose()
|
|
1490
|
-
} catch (_) {}
|
|
1491
|
-
var output = outputText(res)
|
|
1492
|
-
var failed = !!err || (res && res.stopReason && res.stopReason !== 'completed')
|
|
1493
|
-
var errText = err ? String(err) : (res && (res.diagnostic || res.stopReason) || '')
|
|
1494
|
-
try {
|
|
1495
|
-
if (rec.role === 'worker') await settleWorker(sid, rec, output, failed, errText)
|
|
1496
|
-
else if (rec.role === 'hook-pre' || rec.role === 'hook-post') await settleHook(sid, rec, output, failed, errText)
|
|
1497
|
-
else if (rec.role === 'decomposer') await settleDecompose(sid, rec, res, err)
|
|
1498
|
-
else await settleVerifier(sid, rec, output, failed, errText)
|
|
1499
|
-
} catch (e) { console.error('[task-board] settle ' + rec.role + ' failed (task ' + rec.taskId + '):', String(e)) }
|
|
1500
|
-
// 历史会话留档 + usage + 超时臂收尾:三件套抽到 settleRunRecord(上报通道共用同一实现)。
|
|
1501
|
-
// 结局同时是**续跑资格**的唯一依据(continuable + timeout/error|incomplete → 下次重派改续跑),
|
|
1502
|
-
// noResume 旗(interrupt 失败)一并落卡,让重派直接回退 fresh spawn。
|
|
1503
|
-
// ⚠️ 位置在状态推进**之后**:rec.resumeBlocked 由 endContinuable 在推进前写好,这里读到的才是终值。
|
|
1504
|
-
await settleRunRecord(sid, rec, { outcome: failed ? (err ? 'timeout/error' : 'incomplete') : 'completed' })
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
// ===== 可续跑 Worker 的 turn 结算(task-muw5gnhv 卡1;卡2 加续跑基线)=====
|
|
1508
|
-
// 与一次性路径的关系:任务推进语义**完全复用** settleWorker(board_report 工具优先、文本兜底同构),
|
|
1509
|
-
// 差别只在「怎么知道 turn 完了」与「怎么收尾」:
|
|
1510
|
-
// · 一次性:run.result 落定 → settleRun(res,err)(有结构化结果/失败原因);
|
|
1511
|
-
// · continuable:没有 run.result,只能靠 host 事件 agent/status 的 running→idle 观测 turn 结束
|
|
1512
|
-
// (见 onAgentStatus)。因此这里没有 res:输出文本从子会话 v4 日志尾部的 assistant/message 兜底读;
|
|
1513
|
-
// 日志读不到且文本为空时按失败结算(走 pending 重试),**绝不当成「空完成」推进到 verifying**——
|
|
1514
|
-
// 否则一次「没跑起来就 idle」的事件会把任务误判为有交付物。
|
|
1515
|
-
// 收尾三件套(关 run 结局 / usage 落账 / 摘超时臂)不在本函数里单独写一遍,而是随 settleRun →
|
|
1516
|
-
// settleRunRecord 一起做——它与上报通道(board_report → settleReportedRun)共用同一实现
|
|
1517
|
-
// (task-muwkhqf8):两条入口只是「谁来触发」,收尾口径必须只有一套。
|
|
1518
|
-
async function settleContinuable(sid, rec) {
|
|
1519
|
-
if (runsFor(sid)[rec.taskId] !== rec) return // 已被 terminate/硬超时等路径认领
|
|
1520
|
-
var text = ''
|
|
1521
|
-
// 续跑轮(rec.baselineBytes>0)只读基线之后的字节:本轮没写东西就是空文本 → 按失败重排,
|
|
1522
|
-
// 不会把上一轮(被中断那次)残留的助手文本冒充成本轮交付物(同一条失败臂,语义不变)。
|
|
1523
|
-
try { text = childSessionOutput(rec.childId, rec.baselineBytes) } catch (e) { console.error('[task-board] continuable 输出读取失败 (' + rec.childId + '):', String(e)) }
|
|
1524
|
-
if (!text.trim()) console.error('[task-board] continuable child ' + rec.childId + ' idle 但未读到 assistant 文本,按失败结算(任务 ' + rec.taskId + ')')
|
|
1525
|
-
await settleRun(sid, rec, { output: [{ type: 'text', text: text }], stopReason: text.trim() ? 'completed' : 'error' }, null)
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
// continuable 子会话的收尾(卡2:**interrupt 留存,不销毁**)——
|
|
1529
|
-
// 失败/硬超时结算时只发一个取消信号打断当前 turn:Activation、未认领的收件箱、已发布后代全部保留,
|
|
1530
|
-
// 子会话 idle 后仍能被 sendMessage 冷复活(Step2 续跑的前提);一次性路径的 dispose 不动。
|
|
1531
|
-
// authority 形状取 dsh-subagent 的 SubagentInterruptAuthority:{kind:'ancestor', agent: 活的直接父 Agent};
|
|
1532
|
-
// 该调用同步 fire-and-return(发完取消信号即返回,目标可能到下一个可观察点才真正停下)。
|
|
1533
|
-
// interrupt 失败(会话已死 / 权限不符 / 宿主无此能力)→ **绝不阻断结算**:任务照常回 pending 重排;
|
|
1534
|
-
// 只给 rec 立 resumeBlocked 旗 → 该 run 落 noResume 标记,重派时直接 fresh spawn(不空唤醒、不卡死)。
|
|
1535
|
-
async function endContinuable(sid, rec) {
|
|
1536
|
-
if (!rec || !rec.childId) return rec
|
|
1537
|
-
var subagents = ctx.subagents
|
|
1538
|
-
if (!subagents || typeof subagents.interrupt !== 'function') return rec
|
|
1539
|
-
var parent = rootForSession(sid)
|
|
1540
|
-
if (!parent) { rec.resumeBlocked = true; return rec }
|
|
1541
|
-
try {
|
|
1542
|
-
subagents.interrupt(rec.childId, { kind: 'ancestor', agent: parent })
|
|
1543
|
-
} catch (e) {
|
|
1544
|
-
rec.resumeBlocked = true
|
|
1545
|
-
console.error('[task-board] interrupt 失败,该子会话不再续跑(重派回退 fresh spawn)child ' + rec.childId + ':', String(e))
|
|
1546
|
-
}
|
|
1547
|
-
return rec
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
// 读子会话 v4 日志尾部的助手文本(continuable 路径的文本兜底通道):
|
|
1551
|
-
// 与 rpc.mjs agent-activity 同一套 helper/事件形状(assistant/message.content[].text),
|
|
1552
|
-
// 只是这里要的是**最后一条完整助手文本**(供 settleWorker 解析分段格式 / [ESCALATE])。
|
|
1553
|
-
// 只读尾部 2MB:回复在末尾,整份读大日志会拖慢结算路径;读不到一律返回 ''(调用方按失败处理)。
|
|
1554
|
-
// minBytes > 0(续跑轮):只读文件该偏移之后的字节——帧从新到旧,切在半帧上的那一帧由
|
|
1555
|
-
// readLogFrames 逐帧 catch 降级丢弃;本轮没产出任何助手文本就返回 ''(走「空文本按失败」)。
|
|
1556
|
-
function childSessionOutput(childId, minBytes) {
|
|
1557
|
-
if (!childId) return ''
|
|
1558
|
-
// 与 logSizeOf/readRunUsage 同源:显式带上 sessionsRoot(生产注入 undefined → 回退 os.homedir(),
|
|
1559
|
-
// 与本次改造前逐字一致;单测注入临时目录时才读得到真日志——此前漏传会让「文本兜底」这一路永远读空)。
|
|
1560
|
-
var log = findRunLog(childId, sessionsRoot)
|
|
1561
|
-
if (!log) return ''
|
|
1562
|
-
var tail = 2 * 1024 * 1024
|
|
1563
|
-
if (typeof minBytes === 'number' && minBytes > 0) {
|
|
1564
|
-
var sz = 0
|
|
1565
|
-
try { sz = statSync(log).size || 0 } catch (_) { sz = 0 }
|
|
1566
|
-
if (sz <= minBytes) return '' // 续跑基线之后一个字节都没写 → 本轮没有交付物
|
|
1567
|
-
tail = sz - minBytes
|
|
1568
|
-
}
|
|
1569
|
-
var buf = readLogBytes(log, tail)
|
|
1570
|
-
if (!buf) return ''
|
|
1571
|
-
var frames = readLogFrames(buf, 0)
|
|
1572
|
-
var out = ''
|
|
1573
|
-
for (var fi = 0; fi < frames.length; fi++) {
|
|
1574
|
-
var evs = frames[fi]
|
|
1575
|
-
for (var i = 0; i < evs.length; i++) {
|
|
1576
|
-
var e = evs[i]; var dta = (e && e.data) || {}
|
|
1577
|
-
if (!e || e.type !== 'assistant/message') continue
|
|
1578
|
-
var msg = dta.message || {}
|
|
1579
|
-
var blocks = Array.isArray(msg.content) ? msg.content : []
|
|
1580
|
-
var parts = []
|
|
1581
|
-
for (var b = 0; b < blocks.length; b++) { if (blocks[b] && blocks[b].type === 'text' && blocks[b].text) parts.push(String(blocks[b].text)) }
|
|
1582
|
-
if (parts.length) out = parts.join('\n') // 帧从新到旧:最后命中覆盖前值 → 得到最新一条助手文本
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
return out
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
// 本地日期 key(YYYY-MM-DD):byDay 日账的唯一口径。
|
|
1589
|
-
// 必须用本地 getters 拼——toISOString() 是 UTC,晚上 8 点后的消耗会被记到次日,
|
|
1590
|
-
// 「今日消耗」在东八区会从每天 08:00 起算,直接错位。
|
|
1591
|
-
function localDayKey(d) {
|
|
1592
|
-
var x = d || new Date()
|
|
1593
|
-
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
1594
|
-
return x.getFullYear() + '-' + p2(x.getMonth() + 1) + '-' + p2(x.getDate())
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// 有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)。
|
|
1598
|
-
// 为什么单列:本板实测累计 total 里缓存读占 94%(9.7M/10.3M),大数字被缓存读撑高约 17 倍,
|
|
1599
|
-
// 与「真实花掉多少」严重脱节——有效消耗才是可比的成本口径,缓存读单列展示。
|
|
1600
|
-
function effectiveOf(u) {
|
|
1601
|
-
if (!u) return 0
|
|
1602
|
-
return (u.input || 0) + (u.output || 0) + (u.cacheWrite || 0)
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
// ===== usage 增量水位线(卡3 Step1):这一轮该从哪个 seq 之后继续累加 =====
|
|
1606
|
-
// 读同一 rec.id 的历史 runs 条目上落的水位线(runs[i].usageSeq = 上次结算时该会话日志的 maxSeq)。
|
|
1607
|
-
// 为什么锚在 runs 条目而不是内存表:重启后内存表清零而卡上留档还在,续跑轮才能接着上次水位;
|
|
1608
|
-
// 且同一 childId 的多次派发各占一条 runs(resume 轮追加新条目),水位随条目天然分段、互不串账。
|
|
1609
|
-
// ⚠️ 倒序找到「同 id 的条目」还不能停:**续跑轮的新条目与旧条目同 id**(同一会话),新条目此时
|
|
1610
|
-
// 还没结算过、usageSeq 未设——必须继续往前找第一条**已结算过**(usageSeq > 0)的条目,否则水位线
|
|
1611
|
-
// 恒为 0 → 每轮都把整份日志重算一遍(本实现踩过的坑,实测总量双倍 25 而非 15)。
|
|
1612
|
-
// 一条都没有(首次结算 / 手写裁剪过的老任务)→ 0 = 全量累加(与本次改造前逐字一致)。
|
|
1613
|
-
function seekSeqOf(snap, taskId, runId) {
|
|
1614
|
-
try {
|
|
1615
|
-
var tasks = (snap && snap.tasks) || []
|
|
1616
|
-
for (var ti = 0; ti < tasks.length; ti++) {
|
|
1617
|
-
var t = tasks[ti]
|
|
1618
|
-
if (!t || t.id !== taskId || !Array.isArray(t.runs)) continue
|
|
1619
|
-
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
1620
|
-
var r = t.runs[ri]
|
|
1621
|
-
if (!r || String(r.id) !== String(runId)) continue
|
|
1622
|
-
var w = num0(r.usageSeq)
|
|
1623
|
-
if (w > 0) return w
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
} catch (_) {}
|
|
1627
|
-
return 0
|
|
1628
|
-
}
|
|
1629
|
-
// 水位线字段兜底成非负数字(脏值/老形态一律当 0 = 全量,宁可多记也不误丢)
|
|
1630
|
-
function num0(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
|
|
1631
|
-
|
|
1632
|
-
// 该 run 记录是否已落过 usage(usageRecorded 旗):倒序找同 id 的条目,见旗即真。
|
|
1633
|
-
// 判据为什么要**同一 childId 复用**(而不是只认最新条目):continuable 的续跑轮与首轮共用同一个
|
|
1634
|
-
// 子会话 id,closeRunHistory 会把每一轮的条目都更新成终局——「最新那条」在续跑轮结算时正是本轮
|
|
1635
|
-
// 新建的那条(usageRecorded 未置,照常记账)。真重复结算(事件通道与上报通道都来/上报后 idle 补到)
|
|
1636
|
-
// 时最新条目就是那份已记账的旧条目 → 见旗即跳过。两处语义互斥,同一判据够用,不需要另立新字段。
|
|
1637
|
-
function hasUsageRecorded(snap, taskId, runId) {
|
|
1638
|
-
try {
|
|
1639
|
-
var tasks = (snap && snap.tasks) || []
|
|
1640
|
-
for (var ti = 0; ti < tasks.length; ti++) {
|
|
1641
|
-
var t = tasks[ti]
|
|
1642
|
-
if (!t || t.id !== taskId || !Array.isArray(t.runs)) continue
|
|
1643
|
-
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
1644
|
-
var r = t.runs[ri]
|
|
1645
|
-
if (!r || String(r.id) !== String(runId)) continue
|
|
1646
|
-
return r.usageRecorded === true // 同 id 的最新条目就是本轮该结算的那条
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
} catch (_) {}
|
|
1650
|
-
return false
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
// 把一次 run 的 token 消耗累加到任务(t.usage):总量/输入/输出/缓存读写 + 按模型小计 + runs 计数 + 日账。
|
|
1654
|
-
// 模型小计的 key:优先本次派发显式覆盖的模型(rec.model),否则用日志里记录的会话模型。
|
|
1655
|
-
// 日账(byDay):本次 run 整笔记到「结算时刻的本地日」——一次 run 不跨日拆分
|
|
1656
|
-
// (跨零点的长 run 全算在结算日),换取实现极简与仪表盘「今日 / 近 7 天」可算。
|
|
1657
|
-
// 双指标形态:byDay[day] = { t: total, e: effective }(e 是有效消耗,不含缓存读)。
|
|
1658
|
-
// 老数据(number 形态,本轮之前落的日账)只在聚合端兼容:读侧按 { t: n, e: null } 处理,
|
|
1659
|
-
// e 不可知就置 null(宁可展示上标 ~ 近似,也不伪造一个「有效值」)。
|
|
1660
|
-
// 增量口径(卡3 Step1):结算前先读一次看板取该 run 的历史水位线(seekSeqOf),只把这之后的
|
|
1661
|
-
// assistant/message 增量计入;累加时把本份日志的 maxSeq 一并落到该 runs 条目上(下一轮从这继续)。
|
|
1662
|
-
// one-shot 路径(每 run 独立日志、永远找得到自己的条目且首次无水位)→ 行为逐字不变:全量累加一次。
|
|
1663
|
-
async function accumulateRunUsage(sid, rec) {
|
|
1664
|
-
var u = null
|
|
1665
|
-
var since = 0
|
|
1666
|
-
try {
|
|
1667
|
-
var snap0 = await rt(sid)
|
|
1668
|
-
since = seekSeqOf(snap0, rec.taskId, rec.id)
|
|
1669
|
-
// 幂等(task-muwkhqf8):该条 run 记录已落过账 → 直接跳过。为什么需要它:continuable 的 run 现在有
|
|
1670
|
-
// 两条结算入口(事件通道 + board_report 上报通道),且两条可能在「先读到快照、后持锁写」的窗口里
|
|
1671
|
-
// 交错——只靠水位线挡不住同一次结算被记两笔(两次都读到同一旧快照)。旗与累加在同一持有锁回调里
|
|
1672
|
-
// 写,读序天然串行。判据只看**同 id 的最新条目**(见 hasUsageRecorded:续跑轮新条目未置旗,照常记账)。
|
|
1673
|
-
if (hasUsageRecorded(snap0, rec.taskId, rec.id)) return
|
|
1674
|
-
u = readRunUsage(String(rec.id), sessionsRoot, since)
|
|
1675
|
-
} catch (_) { u = null }
|
|
1676
|
-
if (!u || !u.total) return
|
|
1677
|
-
var eff = effectiveOf(u)
|
|
1678
|
-
try {
|
|
1679
|
-
await mutateLocked(sid, function (d) {
|
|
1680
|
-
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
1681
|
-
if (!t) return null
|
|
1682
|
-
if (!t.usage) t.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, runs: 0, models: {} }
|
|
1683
|
-
// 逐字段兜底 0:脏数据(半写/老形态)也不会把 NaN 带进看板(NaN 会让工具输出的 lossless-JSON 校验拒整条结果)
|
|
1684
|
-
t.usage.input = (t.usage.input || 0) + u.input
|
|
1685
|
-
t.usage.output = (t.usage.output || 0) + u.output
|
|
1686
|
-
t.usage.cacheRead = (t.usage.cacheRead || 0) + u.cacheRead
|
|
1687
|
-
t.usage.cacheWrite = (t.usage.cacheWrite || 0) + u.cacheWrite
|
|
1688
|
-
t.usage.total = (t.usage.total || 0) + u.total
|
|
1689
|
-
t.usage.runs = (t.usage.runs || 0) + 1
|
|
1690
|
-
if (!t.usage.models) t.usage.models = {}
|
|
1691
|
-
var mk = rec.model || u.model || '(未知模型)'
|
|
1692
|
-
t.usage.models[mk] = (t.usage.models[mk] || 0) + u.total
|
|
1693
|
-
// 日账:老任务没有 byDay 就地补(不改写老字段形态);键是本地日 YYYY-MM-DD。
|
|
1694
|
-
if (!t.usage.byDay) t.usage.byDay = {}
|
|
1695
|
-
// 脏值兜底:历史/半写数据里 byDay[dk] 可能是 number(老形态)或字符串,一律收敛成对象记账
|
|
1696
|
-
var dk = localDayKey()
|
|
1697
|
-
var cell = t.usage.byDay[dk]
|
|
1698
|
-
if (!cell || typeof cell !== 'object') cell = { t: Number(cell) || 0, e: 0 }
|
|
1699
|
-
cell.t = (Number(cell.t) || 0) + u.total
|
|
1700
|
-
cell.e = (Number(cell.e) || 0) + eff
|
|
1701
|
-
t.usage.byDay[dk] = cell
|
|
1702
|
-
// ===== run 级留账:把本次 run 的用量原样写回它在 t.runs 里的条目 =====
|
|
1703
|
-
// 作用:总量口径万一再要调整(换分母/排除某类 run/按天或按模型重建),不必回头猜——
|
|
1704
|
-
// 每个 run 自带五分量,任意维度都能精确重算(byDay 只是它的一个投影)。
|
|
1705
|
-
// 找不到条目就跳过:t.runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,
|
|
1706
|
-
// 正常结算路径必存在;只有手写/裁剪过的历史任务会缺,缺了也不该阻断聚合累加。
|
|
1707
|
-
// 置位 usageRecorded = 该条 run 已落账(幂等旗,见函数头注释与 hasUsageRecorded):
|
|
1708
|
-
// 与累加同一次持有锁写,读侧据此挡住「同一结算被两条入口各记一笔」。
|
|
1709
|
-
if (Array.isArray(t.runs)) {
|
|
1710
|
-
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
1711
|
-
if (String(t.runs[ri] && t.runs[ri].id) === String(rec.id)) {
|
|
1712
|
-
t.runs[ri].usage = { input: u.input || 0, output: u.output || 0, cacheRead: u.cacheRead || 0, cacheWrite: u.cacheWrite || 0, total: u.total || 0 }
|
|
1713
|
-
// 水位线落卡(卡3 Step1):本份日志已结算到 maxSeq;续跑轮再结算时只认这之后的帧。
|
|
1714
|
-
// 只增不减(max 收敛):异常情况下读到较小值也不让水位倒退(倒退=把已结算帧再记一遍)。
|
|
1715
|
-
t.runs[ri].usageSeq = Math.max(num0(t.runs[ri].usageSeq), num0(u.maxSeq))
|
|
1716
|
-
t.runs[ri].usageRecorded = true
|
|
1717
|
-
// 模型补落账(task-muxhshfu 记分卡卡1①):派发未显式覆盖模型时条目 model='',
|
|
1718
|
-
// 而日志 request/context 里记着真实模型(u.model,与任务级 u.models 小计同源)——
|
|
1719
|
-
// 结算时顺手回填,记分卡卡2 的「模型×场景」聚合就不必再走按任务级占比摊派的近似路径。
|
|
1720
|
-
// 只填空不覆盖:派发显式覆盖的模型名优先(aggregateUsageSummary 模型 key 同一优先序)。
|
|
1721
|
-
if (!t.runs[ri].model && u.model) t.runs[ri].model = String(u.model)
|
|
1722
|
-
break
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
t.usage.updatedAt = new Date().toISOString()
|
|
1727
|
-
return { ok: true }
|
|
1728
|
-
})
|
|
1729
|
-
} catch (e) { console.error('[task-board] usage accumulate failed (task ' + rec.taskId + '):', String(e)) }
|
|
1730
|
-
}
|
|
936
|
+
// 拆解官结算(settleDecompose)已拆至 lib/settle.mjs(拆分第 6 刀 task-mvt8c1yx-6):
|
|
937
|
+
// settleRun 分路模块内直调,本文件不再持有角色结算函数(settleWorker/settleVerifier/settleHook 同批搬出)。
|
|
1731
938
|
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
// 推进到 verifying/resolved——此时若该 turn 的 idle 事件没被观测到(rec.ran 二次守卫挡下的伪 idle、
|
|
1736
|
-
// 驱动未接上的冷窗口、宿主重启、idle 事件丢失),run 记录就永久停在 outcome='running':
|
|
1737
|
-
// ① 软超时臂对它误报(实证:已完成的 run 收到「worker 已运行 30 分钟仍未完成」);
|
|
1738
|
-
// ② usage 不落账(仪表盘漏计这轮消耗);
|
|
1739
|
-
// ③ 硬超时臂到期还对一条早已结算的闲置会话补一枪 interrupt(噪音 + 误导)。
|
|
1740
|
-
// 解法:board_report/board_verdict 把任务推进到落定态(verifying/resolved)时,顺带调本函数——
|
|
1741
|
-
// 对**该任务当前的 continuable Worker rec**补做收尾三件套(关 run 结局 / usage 落账 / 摘超时臂),
|
|
1742
|
-
// 与事件通道共用 settleRunRecord(同一实现,杜绝两条路各写一套口径)。
|
|
1743
|
-
// 三点边界:
|
|
1744
|
-
// · 只认 continuable 且 role==='worker' 的活跃 rec:verifier/hook 是一次性 run。它们的 run.result
|
|
1745
|
-
// 落定后 settleRun 会照常收尾;手动终止路径**刻意**不关 run(保留 running = 不留续跑资格),此处不碰。
|
|
1746
|
-
// · 摘除活跃表项:任务已落定,这张卡不该再占 Worker 并发位;否则 poolCycle 的 activeW 会把它算成
|
|
1747
|
-
// 在跑的 Worker(幽灵占位)。子会话由 settleRunRecord 末尾的 stopSettledOneShot 补停
|
|
1748
|
-
// (task-mvbeallq:completed 落定天然无续跑位——resumeTarget 只认超时/失败结局——「续跑豁免」
|
|
1749
|
-
// 留下的就是残影续跑窗;补停只打断 turn 不销毁会话,与 endContinuable 同款语义)。
|
|
1750
|
-
// · 幂等:closeRunHistory 只关 outcome==='running' 的条目、accumulateRunUsage 认 usageRecorded 旗、
|
|
1751
|
-
// rec.disarm 幂等——重复上报 / 上报后 idle 事件补到,都不会二次关账或二次记账。
|
|
1752
|
-
// 老宿主/没接线的调用方:deps 里没有这个函数时 rpc 侧静默跳过,行为与本次改造前逐字一致。
|
|
1753
|
-
async function settleReportedRun(sid, taskId, opts) {
|
|
1754
|
-
var rec = runsFor(sid)[taskId]
|
|
1755
|
-
if (!rec || !rec.continuable || rec.role !== 'worker') return false
|
|
1756
|
-
if (runsFor(sid)[rec.taskId] !== rec) return false
|
|
1757
|
-
delete runsFor(sid)[rec.taskId] // 与 settleRun 同口径:先摘活跃表项,堵住并发重入
|
|
1758
|
-
rec.settled = true
|
|
1759
|
-
await settleRunRecord(sid, rec, { outcome: (opts && opts.outcome) || 'completed' })
|
|
1760
|
-
return true
|
|
1761
|
-
}
|
|
939
|
+
// usage 水位与落账(localDayKey/effectiveOf/seekSeqOf/num0/hasUsageRecorded/accumulateRunUsage)已拆至
|
|
940
|
+
// lib/settle.mjs(拆分第 7 刀 task-mvt8c1yx-7):settleRunRecord 改模块内直调,return API 的
|
|
941
|
+
// accumulateRunUsage 懒绑定重指 settle 实例(index.mjs 消费面零变化)。
|
|
1762
942
|
|
|
1763
|
-
//
|
|
1764
|
-
//
|
|
1765
|
-
// 「子代理 turn 完成」在事件面就是 running→idle;continuable 没有 run.result promise,故这是唯一可靠的
|
|
1766
|
-
// 结算信号。订阅形态:ctx.on(本插件 host 侧首个事件订阅点),一律走 ctx.effect 回收——
|
|
1767
|
-
// 插件卸载/HMR 重载时监听器必须随之注销,否则重载后同一事件会被多份旧闭包重复结算(重复结算虽被
|
|
1768
|
-
// rec.settled / runsFor 认领双重挡住,但旧闭包里过期的 state 引用本身就是内存泄漏)。
|
|
1769
|
-
// 二次守卫 rec.ran:只有**观测到过 running** 的 rec 才允许被 idle 结算。理由:startContinuable 返回后
|
|
1770
|
-
// 子会话可能先报一个瞬时 idle(驱动尚未接上/冷复活窗口),若拿它结算,任务会以「空文本」被推进/回退。
|
|
1771
|
-
// 真实 turn 必然先 running 后 idle,因此该守卫只挡伪 idle,不挡正常结算。
|
|
1772
|
-
function onAgentStatus(payload) {
|
|
1773
|
-
try {
|
|
1774
|
-
// ⚠️ 不能在此只放行 idle:running 事件正是 rec.ran 的唯一来源(见下方守卫),
|
|
1775
|
-
// 早退会让 rec.ran 永远 false → idle 永远不结算(任务只能等硬超时)——本实现踩过的坑。
|
|
1776
|
-
var status = payload && payload.status
|
|
1777
|
-
if (status !== 'idle' && status !== 'running') return
|
|
1778
|
-
var agent = payload.agent
|
|
1779
|
-
// 身份来源修正(2026-10-06 热修,活体实证):dsh-agent 的 Agent 接口**没有 id 字段**——
|
|
1780
|
-
// 被驱动的会话才是身份(agent.session.id)。此前读 agent.id 恒 undefined → cid='' 早退,
|
|
1781
|
-
// 事件结算通道从卡1上线起从未真正触发(所有结算全靠 run.result / board_report 通道顶着)。
|
|
1782
|
-
// agent.id 作兜底保留(防未来内核补上该字段时形状分叉)。
|
|
1783
|
-
var cid = agent ? String((agent.session && agent.session.id) || agent.id || '') : ''
|
|
1784
|
-
if (!cid) return
|
|
1785
|
-
var table = state.activeRuns
|
|
1786
|
-
// 事件按会话 id 命中活跃 rec:continuable rec 的 childId 就是子会话 id(= agent.session.id),
|
|
1787
|
-
// 故这里是 O(1) 的 rec.childId 比对,不做任何 agent 注册表扫描。
|
|
1788
|
-
Object.keys(table).forEach(function (sid) {
|
|
1789
|
-
var rr = table[sid]; if (!rr) return
|
|
1790
|
-
Object.keys(rr).forEach(function (k) {
|
|
1791
|
-
var rec = rr[k]
|
|
1792
|
-
if (!rec || !rec.continuable || rec.childId !== cid) return
|
|
1793
|
-
if (status === 'running') { rec.ran = true; return }
|
|
1794
|
-
if (!rec.ran || rec.settled) return
|
|
1795
|
-
rec.settled = true // 先立旗:堵住硬超时臂与重复事件的并发重入
|
|
1796
|
-
settleContinuable(sid, rec).catch(function (e) { console.error('[task-board] settleContinuable failed (task ' + rec.taskId + '):', String(e)) })
|
|
1797
|
-
})
|
|
1798
|
-
})
|
|
1799
|
-
} catch (e) { console.error('[task-board] agent/status 处理失败:', String(e)) }
|
|
1800
|
-
}
|
|
1801
|
-
// 可映射帧流的会话 id:continuable 用 childId;activation 一次性 run 在创建时显式记录 childId。
|
|
1802
|
-
// 老 start 的 provider run id 不是会话身份,frameSessionId 为空,sweep 自动保留日志双闸。
|
|
1803
|
-
function frameSessionIdOf(rec) {
|
|
1804
|
-
if (!rec) return ''
|
|
1805
|
-
return String(rec.continuable ? (rec.childId || '') : (rec.frameSessionId || ''))
|
|
1806
|
-
}
|
|
1807
|
-
var frameStreamSeen = false // 本 host 生命周期是否见过任意 assistant-stream 帧(薄桩/mock 零帧时全局兜底)
|
|
1808
|
-
function onAssistantFrame(payload) {
|
|
1809
|
-
try {
|
|
1810
|
-
if (!payload || !payload.frame) return
|
|
1811
|
-
var agent = payload.agent
|
|
1812
|
-
var cid = agent ? String((agent.session && agent.session.id) || agent.id || '') : ''
|
|
1813
|
-
if (!cid) return
|
|
1814
|
-
frameStreamSeen = true
|
|
1815
|
-
var table = state.activeRuns
|
|
1816
|
-
Object.keys(table).forEach(function (sid) {
|
|
1817
|
-
var rr = table[sid]; if (!rr) return
|
|
1818
|
-
Object.keys(rr).forEach(function (k) {
|
|
1819
|
-
var rec = rr[k]
|
|
1820
|
-
if (!rec || rec.settled) return
|
|
1821
|
-
var mapped = frameSessionIdOf(rec)
|
|
1822
|
-
// 事件本身就是身份证据:手工重建/旧形态 rec 若 id 命中,也可安全建立映射;
|
|
1823
|
-
// 老 start 的 provider run id 通常不命中,保持日志闸。
|
|
1824
|
-
if (!mapped && String(rec.id || '') === cid) { rec.frameSessionId = cid; mapped = cid }
|
|
1825
|
-
if (mapped !== cid) return
|
|
1826
|
-
// start/chunk/end 全算活;chunk 不过滤 chunk.type,reasoning-delta 同样刷新。只进内存,绝不逐帧写盘。
|
|
1827
|
-
rec.lastFrameAt = Date.now()
|
|
1828
|
-
rec.frameDirty = true // sweep 据此识别“本轮前有新帧”;不能拿持久基线与时间戳比大小(E2E 会倒推基线)
|
|
1829
|
-
})
|
|
1830
|
-
})
|
|
1831
|
-
} catch (e) { console.error('[task-board] agent/assistant-stream 处理失败:', String(e)) }
|
|
1832
|
-
}
|
|
943
|
+
// 帧活性追踪(frameSessionIdOf/frameStreamSeen/onAssistantFrame)已拆至 lib/hung.mjs(拆分第 1 刀 task-mvt8c1yx),
|
|
944
|
+
// 事件订阅改挂 hung.onAssistantFrame 实例方法(下方)。
|
|
1833
945
|
// 注册(cordis 标准事件订阅):返回的 disposer 交给 ctx.effect 回收。
|
|
1834
946
|
// 老宿主/测试桩上没有 ctx.on 时静默跳过——派发照常,只是没有事件结算通道(一次性路径本就不需要)。
|
|
1835
947
|
if (typeof ctx.on === 'function') {
|
|
1836
948
|
try {
|
|
1837
|
-
ctx.effect(function () { return ctx.on('agent/status', onAgentStatus) })
|
|
949
|
+
ctx.effect(function () { return ctx.on('agent/status', settle.onAgentStatus) })
|
|
1838
950
|
} catch (e) { console.error('[task-board] agent/status 订阅失败:', String(e)) }
|
|
1839
951
|
try {
|
|
1840
|
-
ctx.effect(function () { return ctx.on('agent/assistant-stream', onAssistantFrame) })
|
|
952
|
+
ctx.effect(function () { return ctx.on('agent/assistant-stream', hung.onAssistantFrame) })
|
|
1841
953
|
} catch (e) { console.error('[task-board] agent/assistant-stream 订阅失败:', String(e)) }
|
|
1842
954
|
}
|
|
1843
955
|
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
var doneOn = true
|
|
1852
|
-
var result = await mutateLocked(sid, function (d) {
|
|
1853
|
-
doneOn = cfg(d).notifyDone !== false
|
|
1854
|
-
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
1855
|
-
if (!t) return null
|
|
1856
|
-
// 工具通道已处理(board_report 已推进到 verifying/resolved 或挂了 escalation)→ 只收尾
|
|
1857
|
-
if (t.status !== 'in-progress' || t.escalation) return { task: t, already: true }
|
|
1858
|
-
if (failed) {
|
|
1859
|
-
// 失败原因落卡(截断 300):卡片详情页可直接查看最近失败原因,排查三连败不再靠猜
|
|
1860
|
-
t.lastError = String(errText || '').slice(0, 300)
|
|
1861
|
-
t.retryCount = (t.retryCount || 0) + 1
|
|
1862
|
-
// ===== 宿主秒拆喂熔断(task-mvowb2hc 洞①):spawn/续跑后即被宿主拆除(stopReason:error)
|
|
1863
|
-
// = 模型面失败,计入熔断连击——原形 task-mvoutja4:glm-5.3-flash 秒拆 ×3 不计入 badModels,
|
|
1864
|
-
// 熔断永不触发,档位链每轮重派都再选同一个坏模型。连续 3 次才拉黑(熔断语义不变:badModels
|
|
1865
|
-
// 落黑名单 → 取模链跳过 + 健康表 circuit);worker 自己的失误不计,成功 run 归零(settleRunRecord)。
|
|
1866
|
-
if (rec.model && isModelKillFailure(errText)) feedModelFailure(sid, rec.model, isHungKillFailure(errText) ? HUNG_CIRCUIT_WHY : undefined)
|
|
1867
|
-
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 } }
|
|
1868
|
-
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)')
|
|
1869
|
-
return { task: t, retry: true }
|
|
1870
|
-
}
|
|
1871
|
-
if (/\[ESCALATE\]/i.test(output || '')) {
|
|
1872
|
-
t.escalation = { question: output.slice(0, 2000), at: new Date().toISOString(), by: String(rec.id) }
|
|
1873
|
-
if (!Array.isArray(t.messages)) t.messages = []
|
|
1874
|
-
t.messages.push({ kind: 'escalation', text: output.slice(0, 4000), at: t.escalation.at, by: String(rec.id) })
|
|
1875
|
-
ah(t, 'in-progress', 'in-progress', String(rec.id), 'worker 上报歧义(文本通道),待主窗口裁决')
|
|
1876
|
-
return { task: t, escalated: true }
|
|
1877
|
-
}
|
|
1878
|
-
// 文本降级路径:分段格式上报
|
|
1879
|
-
var secs = parseSections(output)
|
|
1880
|
-
delete t.retryCount; delete t.stuckSince; delete t.lastError // 成功路径:失败计数/卡死标记/最近失败原因一并清除
|
|
1881
|
-
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) }
|
|
1882
|
-
resolveApply(d, t, String(rec.id), 'verifying', output || 'Worker 完成', 'worker 文本上报完成')
|
|
1883
|
-
return { task: t }
|
|
1884
|
-
})
|
|
1885
|
-
if (!result) return
|
|
1886
|
-
// already=true:工具通道(board_report)已推进状态并已发回执/歧义通知,settle 只负责 dispose,不再重复通知
|
|
1887
|
-
if (result.already) return
|
|
1888
|
-
if (result.escalated) maybeNotify(sid, result.task)
|
|
1889
|
-
// 歧义通知(maybeNotify → notifyMainWindow)是裁决通道,不受回执开关影响:只闸下面两条完成/阻塞回执
|
|
1890
|
-
if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
|
|
1891
|
-
if (doneOn && result.blocked) notifyTaskDone(sid, result.task, 'blocked')
|
|
1892
|
-
kickCycle(sid) // 结算后立刻补派
|
|
1893
|
-
}
|
|
956
|
+
// Worker run 结算(settleWorker)已拆至 lib/settle.mjs(拆分第 6 刀 task-mvt8c1yx-6):spawnOneShot
|
|
957
|
+
// 的 activation 拆除兜底臂/异常臂两调用点已改重指 settle.settleWorker(工厂返回集导出)。
|
|
958
|
+
// Verifier run 结算(settleVerifier)已拆至 lib/settle.mjs(拆分第 6 刀 task-mvt8c1yx-6):
|
|
959
|
+
// 验收三连熔断(verifyRetries>=3 转人工)/ 迟到落账 lateVerdictApply / 驳回 pushRejection+pushRejectLesson
|
|
960
|
+
// 链路随源码搬移,语义零变化。
|
|
961
|
+
// hook run 结算(settleHook)已拆至 lib/settle.mjs(拆分第 6 刀 task-mvt8c1yx-6):
|
|
962
|
+
// 状态机纯函数仍在 core.applyHookSettle(单测直接打它),锁外通知 + kickCycle 补派随源码搬移。
|
|
1894
963
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
doneOn = cfg(d).notifyDone !== false
|
|
1900
|
-
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
1901
|
-
if (!t) return null
|
|
1902
|
-
if (t.escalation) return { task: t, already: true }
|
|
1903
|
-
if (t.status !== 'verifying') {
|
|
1904
|
-
// 迟到落账(验收时序三洞 ③):verifier 结论落到已落定卡(主窗口抢批/归档早于本 run 结算)不再静默丢弃。
|
|
1905
|
-
// 但「工具通道已处理」= board_verdict 已由本 run 自己落账(t.verification.by === rec.id)→ 保持 already。
|
|
1906
|
-
if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
|
|
1907
|
-
if (t.verification && String(t.verification.by) === String(rec.id)) return { task: t, already: true }
|
|
1908
|
-
var ltrim = (output || '').trim()
|
|
1909
|
-
var lvm = ltrim.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
|
|
1910
|
-
if (failed || !lvm) return { task: t, already: true } // 无有效结论,不落 late
|
|
1911
|
-
var lapproved = lvm[1].toUpperCase() === 'APPROVED'
|
|
1912
|
-
var lsecs = parseSections(ltrim)
|
|
1913
|
-
var lr = lateVerdictApply(t, lapproved ? 'approved' : 'rejected', lsecs.verifySummary || ltrim.slice(0, 600), lsecs.checks || '', lsecs.userTest, new Date().toISOString(), String(rec.id), cfg(d).verifyUserGuide)
|
|
1914
|
-
if (lr && lr.invalid) return { task: t, already: true } // 三连根修①:非法 verdict 零落账(防御——本路径 verdict 恒来自正则解析)
|
|
1915
|
-
return { task: t, late: true, lateRejected: lr.lateRejected && lr.wasApproved }
|
|
1916
|
-
}
|
|
1917
|
-
return { task: t, already: true }
|
|
1918
|
-
}
|
|
1919
|
-
var trimmed = (output || '').trim()
|
|
1920
|
-
var vm = trimmed.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
|
|
1921
|
-
if (failed || !vm) {
|
|
1922
|
-
// 失败原因落卡(截断 300):verifier 故障/输出无法判定的原因留在卡片上供排查
|
|
1923
|
-
t.lastError = String(errText || 'verifier 未给出有效结论(输出格式异常)').slice(0, 300)
|
|
1924
|
-
// 失败/空输出/无法判定:verifyRetries 计数,>=3 转人工验收(deliverable 已完成,是 verifier 故障不是任务故障)
|
|
1925
|
-
if (failed && rec.model) {
|
|
1926
|
-
badModels[modelKey(sid, rec.model)] = true
|
|
1927
|
-
// 熔断文案分态(task-mv73ym2k,实证 n-mv6ezj6vary4):错误串含 UNKNOWN_MODEL = 模型 id 非法
|
|
1928
|
-
// (mistake 类:配置写错,模型无辜)——与「连续验收失败」(quality 类)区分,不归一堆。
|
|
1929
|
-
var modelIllegal = /UNKNOWN_MODEL/i.test(String(errText || ''))
|
|
1930
|
-
markModelCircuit(sid, rec.model, modelIllegal ? '模型 id 配置非法,已熔断' : '验收连续失败,已熔断')
|
|
1931
|
-
pushSysNote(sid, modelIllegal
|
|
1932
|
-
? '模型 ' + rec.model + ' 配置非法(不在可用清单),已熔断回退父级模型——请经 list-models 选合法 id 重配'
|
|
1933
|
-
: '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型')
|
|
1934
|
-
}
|
|
1935
|
-
t.verifyRetries = (t.verifyRetries || 0) + 1
|
|
1936
|
-
if (t.verifyRetries >= 3) { t.escalation = { question: 'Verifier 连续 ' + t.verifyRetries + ' 次未能给出有效结论(' + (failed ? String(errText).slice(0, 150) : '输出格式异常') + ')。交付物已完成,请人工验收:看板详情页直接通过/驳回,或 task_verify 裁决。', at: new Date().toISOString(), by: 'system' }; ah(t, 'verifying', 'verifying', 'system', 'verifier 故障,转人工验收'); return { task: t, escalated: true } }
|
|
1937
|
-
ah(t, 'verifying', 'verifying', 'system', 'verifier 未给出有效结论,重新排队审查 (' + t.verifyRetries + '/3)')
|
|
1938
|
-
return { task: t, retry: true }
|
|
1939
|
-
}
|
|
1940
|
-
var approved = vm[1].toUpperCase() === 'APPROVED'
|
|
1941
|
-
var vsecs = parseSections(trimmed)
|
|
1942
|
-
delete t.stuckSince; delete t.verifyRetries; delete t.lastError // 成功给出结论:卡死标记/重试计数/最近失败原因一并清除
|
|
1943
|
-
t.verification = { verdict: approved ? 'approved' : 'rejected', summary: vsecs.verifySummary || trimmed.slice(0, 600), checks: vsecs.checks || '', at: new Date().toISOString(), by: String(rec.id) }
|
|
1944
|
-
// 自测指南落账(verifyUserGuide 开关门禁,task-muxyyvg0):开关开且解析出「## 自测指南」段才挂
|
|
1945
|
-
// userTest;缺段/开关关 → 字段不挂(client 详情块与报告「本版自测清单」段整块不渲染)
|
|
1946
|
-
if (cfg(d).verifyUserGuide !== false && vsecs.userTest) t.verification.userTest = vsecs.userTest
|
|
1947
|
-
verifyApply(d, t, String(rec.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
|
|
1948
|
-
if (!approved) {
|
|
1949
|
-
t.rejectCount = (t.rejectCount || 0) + 1
|
|
1950
|
-
// 驳回包全量带回(task-muvg15p5):summary + checks(逐条核对证据)落 messages,
|
|
1951
|
-
// 随 buildMessages 注入重派 Worker prompt——history 里只有 200 字截断,Worker 据此无法返工
|
|
1952
|
-
pushRejection(t, vsecs.verifySummary, vsecs.checks, t.verification.at, String(rec.id))
|
|
1953
|
-
// 学习飞轮 v1:Verifier 驳回 → 候选教训(场景/错误做法/来源),随 t.verification.at 判重
|
|
1954
|
-
pushRejectLesson(d, t, vsecs.verifySummary || trimmed, t.verification.at)
|
|
1955
|
-
if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') }
|
|
1956
|
-
else { t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, 'in-progress', 'pending', 'system', '驳回重派:新 Worker 将携带驳回原因继续') }
|
|
1957
|
-
}
|
|
1958
|
-
return { task: t, approved: approved }
|
|
1959
|
-
})
|
|
1960
|
-
if (!result) return
|
|
1961
|
-
// already=true:工具通道(board_verdict)已推进状态并已发回执,settle 只负责 dispose,不再重复通知
|
|
1962
|
-
if (result.already) return
|
|
1963
|
-
// 迟到通知分态(三连根修②③,task-mvf7nve8):rejected 且卡已 approved → err 级「🚨 迟到驳回」+
|
|
1964
|
-
// healthHint(banner 只 rejected 亮);approved → notice 级「✅ 迟到复核:维持原判」。
|
|
1965
|
-
// 同指纹(taskId+by+verdict,poolHealth.lastLateNotice)已投过 → 跳过,零重复通知。
|
|
1966
|
-
if (result.late) {
|
|
1967
|
-
var __phL = poolHealthFor(sid)
|
|
1968
|
-
var __fpL = lateNoticeFp(result.task)
|
|
1969
|
-
var __onceL = __phL.lastLateNotice !== __fpL
|
|
1970
|
-
if (__onceL) __phL.lastLateNotice = __fpL
|
|
1971
|
-
if (result.lateRejected) {
|
|
1972
|
-
__phL.lateRejectNote = { taskId: result.task.id, at: Date.now() } // banner 幂等 sticky,不受去重闸影响
|
|
1973
|
-
if (__onceL && notifyLateReject) notifyLateReject(sid, result.task)
|
|
1974
|
-
} else if (__onceL && notifyLateApprove && result.task.lateVerdict && result.task.lateVerdict.verdict === 'approved') {
|
|
1975
|
-
notifyLateApprove(sid, result.task)
|
|
1976
|
-
}
|
|
1977
|
-
return
|
|
1978
|
-
}
|
|
1979
|
-
if (result.escalated) maybeNotify(sid, result.task)
|
|
1980
|
-
if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
|
|
1981
|
-
if (doneOn && result.task && result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
|
|
1982
|
-
kickCycle(sid)
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
// ===== hook run 结算(hooks=agent run 接线②)=====
|
|
1986
|
-
// 照 worker/verifier 的结算模式:mutateLocked 持锁改状态机(状态机纯函数在 core.applyHookSettle,
|
|
1987
|
-
// 单测直接打它),锁外发通知 + kickCycle 补派。
|
|
1988
|
-
// hook-pre 完成 → hooks.pre.state='done'(下轮起子任务正常放行,串行闸门打开)
|
|
1989
|
-
// hook-post 完成 → hooks.post.state='done' + epic 转 verifying(收口完成,交人验收)
|
|
1990
|
-
// 失败(异常/stopReason 异常)或文本 [ESCALATE] → 该点位 state='failed' + epic 转 blocked +
|
|
1991
|
-
// escalation 挂卡(歧义上报「前置准备失败,重试/跳过/放弃」),不自动重试。
|
|
1992
|
-
// 幂等:epic 已有未裁决 escalation 时只收口不覆盖(主窗口裁决前不刷屏)。
|
|
1993
|
-
async function settleHook(sid, rec, output, failed, errText) {
|
|
1994
|
-
var phase = rec.role === 'hook-pre' ? 'pre' : 'post'
|
|
1995
|
-
var esc = /\[ESCALATE\]/i.test(output || '')
|
|
1996
|
-
var runId = String(rec.id)
|
|
1997
|
-
// 完成回执开关(notifyDone):hook 收口把 epic 推进到 blocked/verifying 同样算完成回执,一视同仁
|
|
1998
|
-
var doneOn = true
|
|
1999
|
-
var result = await mutateLocked(sid, function (d) {
|
|
2000
|
-
doneOn = cfg(d).notifyDone !== false
|
|
2001
|
-
var p = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
2002
|
-
if (!p) return null
|
|
2003
|
-
return applyHookSettle(d, rec.taskId, phase, !(failed || esc), output, runId, errText)
|
|
2004
|
-
})
|
|
2005
|
-
if (!result) return
|
|
2006
|
-
if (result.already) return
|
|
2007
|
-
if (result.blocked) { maybeNotify(sid, result.task); if (doneOn) notifyTaskDone(sid, result.task, 'blocked') }
|
|
2008
|
-
if (doneOn && result.closed) notifyTaskDone(sid, result.task, 'resolved')
|
|
2009
|
-
kickCycle(sid) // 结算后立刻补派(pre 完成 → 子任务开跑;post 完成 → epic 进验收)
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
// ===== 重启 reconcile(卡3 Step2):host 重启后把活跃 continuable Worker 的观测重新挂回来 =====
|
|
2013
|
-
// 问题:continuable Worker 的 rec 只活在内存(state.activeRuns)——host 重启把它清零,而子会话
|
|
2014
|
-
// 本身是**持久子会话**,可能正跑得好好(或刚跑完一轮)。此时任务停在 in-progress 却没人认领:
|
|
2015
|
-
// 既不会被 agent/status 结算(表里没 rec),又不会被孤儿回收(isOrphan 的 2 分钟门槛+它确实
|
|
2016
|
-
// 有 claimedBy),只能干等——重启一次就白挂一个 Worker 位。
|
|
2017
|
-
// 解法(一次,per host 生命周期):对本会话板上「in-progress 且无活跃 rec」的任务查 listChildren(root),
|
|
2018
|
-
// · 该 childId 仍在列 → 判定存活:重建 rec 观测(agent/status 监听本来就在,登记即可续上),
|
|
2019
|
-
// 基线取**当前日志字节数**(重启前那轮的产出已落盘,不该冒充重启后的新交付物),重挂两级超时臂;
|
|
2020
|
-
// · 不在列 → 会话已死:走「硬超时等价物」——失败结算的等价动作(回 pending 重排 + 留历史),
|
|
2021
|
-
// 不占用 Worker 并发位,等下一轮 poolCycle 正常重派(续跑资格由卡上 runs 留档决定)。
|
|
2022
|
-
// 三道门禁:①只对 role=worker + continuable + 结局停在 running 的 run(一次性 run 没有可找回的会话);
|
|
2023
|
-
// ②per host 生命周期只做一次(state.reconcileDone)——重复做会把用户手动终止的卡又挂回去;
|
|
2024
|
-
// ③listChildren 不可用(老宿主/测试桩)→ 整段跳过,行为逐字不变。
|
|
2025
|
-
// 已知边界(有意不做,交给既有机制):重启**之后**才被中断的那一轮,reconcile 追不回它的结局——
|
|
2026
|
-
// 硬超时臂已在重建时重挂,到点照常结算。
|
|
2027
|
-
async function reconcileRoot(sid) {
|
|
2028
|
-
if (!state.reconcileDone) state.reconcileDone = {} // 老宿主/测试桩没建这个容器:就地补(不清空已有标记)
|
|
2029
|
-
if (state.reconcileDone[sid]) return
|
|
2030
|
-
state.reconcileDone[sid] = true
|
|
2031
|
-
var subagents = ctx.subagents
|
|
2032
|
-
if (!subagents || typeof subagents.listChildren !== 'function') return
|
|
2033
|
-
var runs = runsFor(sid)
|
|
2034
|
-
var snap = null
|
|
2035
|
-
try { snap = await rt(sid) } catch (_) { return }
|
|
2036
|
-
// 候选:in-progress + 在跑占位已换成真实 run id + 无活跃 rec + 最后一条 worker run 是 continuable
|
|
2037
|
-
// 且结局仍停在 running(已落 timeout/error 的卡由重派路径自己处理,不在此处抢跑)。
|
|
2038
|
-
var cands = []
|
|
2039
|
-
var list = (snap && snap.tasks) || []
|
|
2040
|
-
for (var i = 0; i < list.length; i++) {
|
|
2041
|
-
var t = list[i]
|
|
2042
|
-
if (!t || t.status !== 'in-progress' || t.escalation) continue
|
|
2043
|
-
if (runs[t.id]) continue
|
|
2044
|
-
if (!t.claimedBy || t.claimedBy === 'spawn-pending') continue
|
|
2045
|
-
if (resumeTarget(t) !== '') continue // 结局已落超时/失败 → 归重派续跑路径,reconcile 不掺和
|
|
2046
|
-
var last = null
|
|
2047
|
-
var trs = Array.isArray(t.runs) ? t.runs : []
|
|
2048
|
-
for (var ri = trs.length - 1; ri >= 0; ri--) { if (trs[ri] && trs[ri].role === 'worker') { last = trs[ri]; break } }
|
|
2049
|
-
if (!last || last.continuable !== true || last.outcome !== 'running') continue
|
|
2050
|
-
if (String(last.id) !== String(t.claimedBy)) continue // 卡上留档与占位不一致:状态可疑,不动
|
|
2051
|
-
cands.push(t)
|
|
2052
|
-
}
|
|
2053
|
-
// ===== 孤儿一次性子会话补停(task-mv8wuznb ②,重启场景)=====
|
|
2054
|
-
// 卡已落定(resolved/cancelled/archived)而 verifier/一次性 worker 的 run 留档仍 running 无
|
|
2055
|
-
// endedAt:重启把内存 rec 清零后,结算收尾(stopSettledOneShot)的 interrupt 再也打不到它——
|
|
2056
|
-
// 若子会话仍活在宿主目录(下方 listChildren 命中),顺手 interrupt 停掉(「结算即销毁」的重启补课,
|
|
2057
|
-
// 实证:verifier 空转 8h 的会话若中途遇 host 重启,苏醒后照旧空烧)。
|
|
2058
|
-
// 只停会话、不动卡面:running 留档的关账归 sweepZombieRuns(每轮都跑,就排在本函数之后)。
|
|
2059
|
-
// continuable worker 豁免(留存会话供续跑是设计语义;在飞卡的 continuable 由上方候选①重建观测)。
|
|
2060
|
-
var orphans = []
|
|
2061
|
-
for (var oi = 0; oi < list.length; oi++) {
|
|
2062
|
-
var ot = list[oi]
|
|
2063
|
-
if (!ot || (ot.status !== 'resolved' && ot.status !== 'cancelled' && ot.status !== 'archived')) continue
|
|
2064
|
-
var ors = Array.isArray(ot.runs) ? ot.runs : []
|
|
2065
|
-
for (var oj = 0; oj < ors.length; oj++) {
|
|
2066
|
-
var orun = ors[oj]
|
|
2067
|
-
if (!orun || orun.outcome !== 'running' || orun.endedAt) continue
|
|
2068
|
-
if (orun.role !== 'verifier' && !(orun.role === 'worker' && orun.continuable !== true)) continue
|
|
2069
|
-
orphans.push(String(orun.id))
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
if (!cands.length && !orphans.length) return
|
|
2073
|
-
// 存活判定:listChildren 列出的就是「活的子会话」。带超时保护(查询挂死也不能拖住心跳)。
|
|
2074
|
-
var kids = null
|
|
2075
|
-
try {
|
|
2076
|
-
var q = subagents.listChildren(sid)
|
|
2077
|
-
kids = (typeof withTimeout === 'function') ? await withTimeout(q, 5000, 'reconcile listChildren') : await q
|
|
2078
|
-
} catch (e) {
|
|
2079
|
-
console.error('[task-board] reconcile listChildren 失败(本轮跳过,卡片留在 in-progress 等人工处理):', String(e))
|
|
2080
|
-
return
|
|
2081
|
-
}
|
|
2082
|
-
var alive = {}
|
|
2083
|
-
var kidList = Array.isArray(kids) ? kids : []
|
|
2084
|
-
for (var k = 0; k < kidList.length; k++) {
|
|
2085
|
-
var c = kidList[k]; if (!c) continue
|
|
2086
|
-
var cid = String(c.sessionId || c.id || '')
|
|
2087
|
-
if (cid) alive[cid] = true
|
|
2088
|
-
}
|
|
2089
|
-
var c2 = cfg(snap)
|
|
2090
|
-
var found = 0, lost = 0
|
|
2091
|
-
for (var ci = 0; ci < cands.length; ci++) {
|
|
2092
|
-
var tc = cands[ci]
|
|
2093
|
-
var childId = String(tc.claimedBy)
|
|
2094
|
-
if (alive[childId]) {
|
|
2095
|
-
// ① 存活:重建 rec(与 spawnOneShot 的 continuable 形态逐字同构)→ 重挂两级超时臂。
|
|
2096
|
-
// ran:false —— 重启前的 running 事件已经错过,等下一个真 running 事件到来才允许 idle 结算
|
|
2097
|
-
// (rec.ran 二次守卫的既有语义:宁可等,也不拿一个可能已在跑的会话的空文本当交付物)。
|
|
2098
|
-
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) }
|
|
2099
|
-
runs[tc.id] = rec
|
|
2100
|
-
if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
|
|
2101
|
-
dispatchedEver[sid][childId] = true
|
|
2102
|
-
armTimeouts(sid, rec, tc, c2)
|
|
2103
|
-
found++
|
|
2104
|
-
// 系统消息只留一行(重启是低频事件,且这行是「为什么它还在跑」的唯一解释来源)
|
|
2105
|
-
pushSysNote(sid, '任务「' + tc.title + '」的 Worker 子会话已在重启后找回(续跑观测已恢复,硬超时 ' + c2.hardTimeoutMin + ' 分钟后照常结算)', tc.id)
|
|
2106
|
-
} else lost++
|
|
2107
|
-
}
|
|
2108
|
-
if (found || lost) console.log('[task-board] 重启 reconcile(' + sid + '):找回活跃续跑 Worker ' + found + ' 个,' + lost + ' 个子会话已死转回 pending')
|
|
2109
|
-
// ② 会话已死:硬超时等价物(失败结算的等价动作)——回 pending 重排 + 留一行 history + 落结局。
|
|
2110
|
-
// 放在**锁外**统一做(一次持锁批量处理,避免逐张写盘打断派发周期)。
|
|
2111
|
-
if (lost) {
|
|
2112
|
-
try {
|
|
2113
|
-
await mutateLocked(sid, function (d) {
|
|
2114
|
-
d.tasks.forEach(function (t) {
|
|
2115
|
-
if (t.status !== 'in-progress' || runs[t.id] || !t.claimedBy || t.claimedBy === 'spawn-pending') return
|
|
2116
|
-
var last = null
|
|
2117
|
-
var trs = Array.isArray(t.runs) ? t.runs : []
|
|
2118
|
-
for (var ri = trs.length - 1; ri >= 0; ri--) { if (trs[ri] && trs[ri].role === 'worker') { last = trs[ri]; break } }
|
|
2119
|
-
if (!last || last.continuable !== true || last.outcome !== 'running') return
|
|
2120
|
-
if (String(last.id) !== String(t.claimedBy) || alive[String(t.claimedBy)]) return
|
|
2121
|
-
var ps = t.status
|
|
2122
|
-
t.status = 'pending'; t.claimedBy = null; t.claimedAt = null
|
|
2123
|
-
t.lastError = 'host 重启后 Worker 子会话已不存在(reconcile 未找回),任务回收重排'
|
|
2124
|
-
last.outcome = 'timeout/error'; last.endedAt = new Date().toISOString()
|
|
2125
|
-
ah(t, ps, 'pending', 'system', '重启后执行会话已丢失,回收重新排队')
|
|
2126
|
-
return
|
|
2127
|
-
})
|
|
2128
|
-
return { ok: true }
|
|
2129
|
-
}, true)
|
|
2130
|
-
} catch (e) { console.error('[task-board] reconcile 死会话回收失败:', String(e)) }
|
|
2131
|
-
}
|
|
2132
|
-
// 孤儿一次性子会话补停(task-mv8wuznb ②):只打「仍在活跃目录」的——不在目录 = 会话已死,
|
|
2133
|
-
// 杀无可杀(留档归 sweepZombieRuns 关账);无 interrupt 能力/无活 root → 静默跳过;
|
|
2134
|
-
// 单条失败只 console.error,不拖住本轮 reconcile 的其它动作。
|
|
2135
|
-
var stopped = 0
|
|
2136
|
-
if (orphans.length && typeof subagents.interrupt === 'function') {
|
|
2137
|
-
var oroot = rootForSession(sid)
|
|
2138
|
-
if (oroot) {
|
|
2139
|
-
for (var oi2 = 0; oi2 < orphans.length; oi2++) {
|
|
2140
|
-
var oid = orphans[oi2]
|
|
2141
|
-
if (!alive[oid]) continue
|
|
2142
|
-
try { subagents.interrupt(oid, { kind: 'ancestor', agent: oroot }); stopped++ }
|
|
2143
|
-
catch (e) { console.error('[task-board] reconcile 孤儿一次性子会话 interrupt 失败(' + oid + '):', String(e)) }
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
if (stopped) console.log('[task-board] 重启 reconcile(' + sid + '):补停落定卡孤儿一次性子会话 ' + stopped + ' 个')
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
// ===== 僵尸 run 留档清扫(task-mv22z61v):关账「落定卡上的 running 无 endedAt 留档」+ 清卡面认领位 =====
|
|
2151
|
-
// 事故(用户实证):0.4.1/0.4.5 时代老卡,宿主多次重启后 run 留档停在 outcome:'running' 无 endedAt +
|
|
2152
|
-
// 卡面 verifierRun/claimedBy 残留指向死会话。归档门禁 activeRunOf 的卡面兜底(liveEntry 认 claimedBy/
|
|
2153
|
-
// verifierRun 匹配的 running 留档)把它们误判活跃 → archive 永久拒。mv1pqy27 的 doTerminate 状态无关
|
|
2154
|
-
// 清理只覆盖「rec 在内存表」场景;重启后 rec 消失 + 卡已落定的组合落入盲区(resolved 无分支,什么都不清),
|
|
2155
|
-
// 且没有任何工具能关一条无 rec 的 run 留档本体。
|
|
2156
|
-
// 修法:扫描本板卡片的 runs[],凡 outcome==='running' 且无 endedAt 且该 run id 不在内存活跃表(runsFor)
|
|
2157
|
-
// 且所属卡已落定(resolved/cancelled/archived)→ 关账(endedAt=now,outcome 改 'stale-closed',注记
|
|
2158
|
-
// note='host 重启僵尸留档清扫')+ 清对应卡面认领位(verifierRun/claimedBy 若指向它)。
|
|
2159
|
-
// 边界(宁少勿多):
|
|
2160
|
-
// ① 只对落定卡做——在飞卡的 running 留档是 reconcile / 结算通道的辖区,别越界;
|
|
2161
|
-
// ② 无活跃 rec 才扫——内存表里仍活着(!settled)的 run 留给结算通道 / reapGhostRecs 关账(它们用
|
|
2162
|
-
// 'completed'/'timeout-error' 等真结局,而非这里的 'stale-closed',口径更准);
|
|
2163
|
-
// ③ 幂等——关账后 outcome!=running,二次运行零改动;回调无改动时返回 null 不写盘(沿用空闲快进的
|
|
2164
|
-
// 「不写盘」纪律,安静板不因清扫产生 15s 心跳写盘);
|
|
2165
|
-
// ④ 留痕——history 行 + console 一行(有产出时)。
|
|
2166
|
-
// 'stale-closed' 是既有枚举之外的新结局值:详情页/聚合端对未知 outcome 一律按「已结束(有 endedAt)」
|
|
2167
|
-
// 兜底展示,不会把它误判成 running(running 的判据是 outcome==='running' 且无 endedAt,二者已同时改写)。
|
|
2168
|
-
async function sweepZombieRuns(sid, snap) {
|
|
2169
|
-
if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0 // 瞬时读盘失败/空板:跳过,不写盘
|
|
2170
|
-
// 预扫快照:有候选(落定卡 + running 无 endedAt 留档)才进锁写;否则零 mutateLocked 调用——
|
|
2171
|
-
// 空板/安静板走空闲快进「不写盘」的纪律不受清扫破坏(每 15s 心跳也不能因清扫多出一次写盘调用)。
|
|
2172
|
-
var hasCandidate = false
|
|
2173
|
-
for (var pi = 0; pi < snap.tasks.length && !hasCandidate; pi++) {
|
|
2174
|
-
var pt = snap.tasks[pi]
|
|
2175
|
-
if (!pt || (pt.status !== 'resolved' && pt.status !== 'cancelled' && pt.status !== 'archived')) continue
|
|
2176
|
-
var prs = Array.isArray(pt.runs) ? pt.runs : []
|
|
2177
|
-
for (var pj = 0; pj < prs.length; pj++) { if (prs[pj] && prs[pj].outcome === 'running' && !prs[pj].endedAt) { hasCandidate = true; break } }
|
|
2178
|
-
}
|
|
2179
|
-
if (!hasCandidate) return 0
|
|
2180
|
-
var runs = runsFor(sid)
|
|
2181
|
-
var swept = []
|
|
2182
|
-
await mutateLocked(sid, function (d) {
|
|
2183
|
-
if (!d || !Array.isArray(d.tasks)) return null
|
|
2184
|
-
var any = false
|
|
2185
|
-
d.tasks.forEach(function (t) {
|
|
2186
|
-
if (!t) return
|
|
2187
|
-
if (t.status !== 'resolved' && t.status !== 'cancelled' && t.status !== 'archived') return // 只清扫已落定卡
|
|
2188
|
-
if (!Array.isArray(t.runs)) return
|
|
2189
|
-
var cardChanged = false
|
|
2190
|
-
t.runs.forEach(function (r) {
|
|
2191
|
-
if (!r || r.outcome !== 'running' || r.endedAt) return // 只关「还开着且无 endedAt」的条目
|
|
2192
|
-
var rid = String(r.id)
|
|
2193
|
-
var rec = runs[t.id]
|
|
2194
|
-
if (rec && !rec.settled && String(rec.id) === rid) return // 内存活跃表里还活着 → 结算通道辖区,不扫
|
|
2195
|
-
r.outcome = 'stale-closed'
|
|
2196
|
-
r.endedAt = new Date().toISOString()
|
|
2197
|
-
r.note = 'host 重启僵尸留档清扫'
|
|
2198
|
-
if (t.verifierRun === rid) { t.verifierRun = null; delete t.verifierRunAt }
|
|
2199
|
-
if (t.claimedBy === rid) { t.claimedBy = null; t.claimedAt = null }
|
|
2200
|
-
swept.push(t.id + '/' + rid + '/' + (r.role || '?'))
|
|
2201
|
-
cardChanged = true
|
|
2202
|
-
})
|
|
2203
|
-
if (cardChanged) { ah(t, t.status, t.status, 'system', '僵尸 run 留档已关账(host 重启清扫)'); any = true }
|
|
2204
|
-
})
|
|
2205
|
-
return any ? d : null // 无改动返回 null → 不写盘(安静板不因清扫产生心跳写盘)
|
|
2206
|
-
}, true) // skipKick:本轮已是 poolCycle,无需再踢
|
|
2207
|
-
if (swept.length) console.log('[task-board] 僵尸 run 留档清扫 ' + swept.length + ' 条:' + swept.join(', '))
|
|
2208
|
-
return swept.length
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
// ===== 挂死零产出检测(task-mvqcoz9l):在跑 worker/verifier run 日志字节零增长判死重派+喂熔断 =====
|
|
2212
|
-
// 原形(2026-10-27 降级实测):glm-5.2 续跑挂上「连接开着永不响应」形态——会话不死(死会话哨兵
|
|
2213
|
-
// listChildren 查不出)、不报错(无 settle 事件)、零产出(日志零增长 7 分钟),running 假相直到
|
|
2214
|
-
// 人工终止。熔断/冷却/哨兵全部够不着它。修法:每轮对在跑 worker/verifier run 比对会话日志字节数
|
|
2215
|
-
// (logSizeOf)与 run 留档的 lastLogBytes(新字段,每轮写回):连续 HUNG_ZERO_GROWTH_ROUNDS 轮
|
|
2216
|
-
// 零增长且距基线 ≥600s 墙钟地板(固定值,task-mvrt9tn5 二度校准:延迟缩放作废)→ 判定挂死:settleRun 失败臂(记「挂死零产出」,errText
|
|
2217
|
-
// 前缀 HUNG_KILL_PREFIX 进 isModelKillFailure 白名单)+ 计入该模型熔断连击(挂死=模型面故障)+
|
|
2218
|
-
// 卡重派(settleWorker 失败臂回 pending / settleVerifier 失败臂重排审查)。活体增长即清零计数。
|
|
2219
|
-
// 边界(宁少勿多):
|
|
2220
|
-
// ① 只盖 worker/verifier 在跑 rec(!settled)——decomposer/hook 产出形态不同,不进本检测;
|
|
2221
|
-
// ② 留档行缺失(rec 刚 spawn、recordRunHistory 未落盘)→ 无锚点跳过,下轮再判,绝不在无锚点时判死;
|
|
2222
|
-
// ③ 首见 lastLogBytes 只记基线不计数(老数据无字段同理)——绝不在第一眼把可能真在跑的 run 判死;
|
|
2223
|
-
// ④ 活体增长(size>prev)即清零计数+刷新基线(零增长窗口重新起算)——正常 run 行为零变化;
|
|
2224
|
-
// ⑤ 字节预读在锁外(logSizeOf 走 findRunLog/statSync),比对与计数在锁内(与留档行同一次原子写);
|
|
2225
|
-
// ⑥ 判死结算在锁外:settleRun 自带认领守卫(runsFor[taskId]!==rec 早退)+ 自己的锁段——结算通道
|
|
2226
|
-
// 抢先(事件/上报/终止)则跳过,绝不重复判死;幂等(判死后 rec 摘表,下轮零候选)。
|
|
2227
|
-
// ⑦ 返回本轮判死条数(0=无候选/无判死;调用方据此决定要不要重读快照)。
|
|
2228
|
-
async function hungZeroOutputSweep(sid, runs, snap) {
|
|
2229
|
-
var cands = []
|
|
2230
|
-
Object.keys(runs).forEach(function (k) {
|
|
2231
|
-
var rec = runs[k]
|
|
2232
|
-
if (rec && !rec.settled && (rec.role === 'worker' || rec.role === 'verifier')) cands.push(rec)
|
|
2233
|
-
})
|
|
2234
|
-
if (!cands.length || !snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0
|
|
2235
|
-
// ⑤ 字节预读在锁外且只服务兜底候选:帧流已生效且 rec 可映射时不再读日志。
|
|
2236
|
-
var sizes = {}
|
|
2237
|
-
cands.forEach(function (rec) {
|
|
2238
|
-
if (frameStreamSeen && frameSessionIdOf(rec)) return
|
|
2239
|
-
sizes[String(rec.id)] = logSizeOf(String(rec.childId || rec.id))
|
|
2240
|
-
})
|
|
2241
|
-
var hung = [], hungWhy = {}
|
|
2242
|
-
await mutateLocked(sid, function (d) {
|
|
2243
|
-
if (!d || !Array.isArray(d.tasks)) return null
|
|
2244
|
-
var any = false
|
|
2245
|
-
cands.forEach(function (rec) {
|
|
2246
|
-
try {
|
|
2247
|
-
var rid = String(rec.id)
|
|
2248
|
-
var t = null
|
|
2249
|
-
for (var i = 0; i < d.tasks.length; i++) { if (d.tasks[i] && d.tasks[i].id === rec.taskId) { t = d.tasks[i]; break } }
|
|
2250
|
-
if (!t || !Array.isArray(t.runs)) return // ② 卡不存在/无留档:无锚点不判
|
|
2251
|
-
var row = null
|
|
2252
|
-
for (var j = t.runs.length - 1; j >= 0; j--) {
|
|
2253
|
-
var r0 = t.runs[j]
|
|
2254
|
-
if (r0 && String(r0.id) === rid && r0.outcome === 'running') { row = r0; break } // 本轮在跑的那条(同 id 首派+续跑取最新)
|
|
2255
|
-
}
|
|
2256
|
-
if (!row) return // ② 留档缺失:无锚点不判
|
|
2257
|
-
// ===== v2 帧活性主路径:全局见过帧 + 本 rec 有可映射会话 id 才启用 =====
|
|
2258
|
-
// 薄桩/mock/老 host 零帧,或老 start provider run id 不可映射 → 直接落下方原日志双闸,存量语义逐字不变。
|
|
2259
|
-
if (frameStreamSeen && frameSessionIdOf(rec)) {
|
|
2260
|
-
var nowFrame = Date.now()
|
|
2261
|
-
var lastFrameAt = Number(rec.lastFrameAt || 0)
|
|
2262
|
-
var frameSince = typeof row.frameSilenceSince === 'number' ? row.frameSilenceSince : 0
|
|
2263
|
-
// 事件只置内存 dirty,sweep 消费后才算“帧恢复”:删持久基线;静默未满 600s 时同步清疑似标卡。
|
|
2264
|
-
// 刻意不比较 frameSince < lastFrameAt——E2E 倒推持久基线也满足该大小关系,会把判死编排误吃成恢复。
|
|
2265
|
-
var hadFrame = rec.frameDirty === true
|
|
2266
|
-
rec.frameDirty = false
|
|
2267
|
-
if (hadFrame) {
|
|
2268
|
-
var recoverySilence = frameSince ? nowFrame - frameSince : 0
|
|
2269
|
-
if (frameSince) { delete row.frameSilenceSince; any = true }
|
|
2270
|
-
if (t.stuckSince && recoverySilence < HUNG_FRAME_KILL_MS) {
|
|
2271
|
-
delete t.stuckSince
|
|
2272
|
-
ah(t, t.status, t.status, String(rec.id), 'assistant 帧恢复(静默不足 600s),清除疑似卡顿标记')
|
|
2273
|
-
any = true
|
|
2274
|
-
}
|
|
2275
|
-
return
|
|
2276
|
-
}
|
|
2277
|
-
// 零帧 sweep:基线取最近帧,未见过本会话帧则取 startedAt;仅基线缺失时写,安静轮不刷盘。
|
|
2278
|
-
// E2E 可 patchBoard 倒推 frameSilenceSince(虚拟时钟推不动 Date.now),与旧日志基线同款可测性。
|
|
2279
|
-
if (!frameSince) {
|
|
2280
|
-
frameSince = lastFrameAt || Number(rec.startedAt || nowFrame)
|
|
2281
|
-
row.frameSilenceSince = frameSince
|
|
2282
|
-
any = true
|
|
2283
|
-
}
|
|
2284
|
-
var frameSilentMs = nowFrame - frameSince
|
|
2285
|
-
if (frameSilentMs >= HUNG_FRAME_KILL_MS) {
|
|
2286
|
-
hung.push(rec)
|
|
2287
|
-
hungWhy[rid] = HUNG_KILL_PREFIX + '帧静默 ' + Math.round(HUNG_FRAME_KILL_MS / 1000) + 's,判挂死按模型面故障结算重派'
|
|
2288
|
-
return
|
|
2289
|
-
}
|
|
2290
|
-
if (frameSilentMs >= HUNG_FRAME_SUSPECT_MS && !t.stuckSince) {
|
|
2291
|
-
t.stuckSince = nowFrame
|
|
2292
|
-
ah(t, t.status, t.status, String(rec.id), '🟡 疑似卡顿:assistant 帧静默 ' + Math.round(HUNG_FRAME_SUSPECT_MS / 1000) + 's(仅标卡,不终止)')
|
|
2293
|
-
any = true
|
|
2294
|
-
}
|
|
2295
|
-
return
|
|
2296
|
-
}
|
|
2297
|
-
var size = sizes[rid]
|
|
2298
|
-
var prev = typeof row.lastLogBytes === 'number' ? row.lastLogBytes : null
|
|
2299
|
-
if (prev === null) { row.lastLogBytes = size; row.zeroGrowthSince = Date.now(); any = true; return } // ③ 首见只记基线
|
|
2300
|
-
if (size > prev) { // ④ 活体增长:清零计数 + 刷新基线(零增长窗口重新起算)
|
|
2301
|
-
row.lastLogBytes = size; row.zeroGrowthSince = Date.now()
|
|
2302
|
-
if (row.zeroGrowthRounds) delete row.zeroGrowthRounds
|
|
2303
|
-
any = true; return
|
|
2304
|
-
}
|
|
2305
|
-
row.zeroGrowthRounds = (row.zeroGrowthRounds || 0) + 1
|
|
2306
|
-
any = true
|
|
2307
|
-
var since = typeof row.zeroGrowthSince === 'number' ? row.zeroGrowthSince : 0
|
|
2308
|
-
// 墙钟闸(task-mvrt9tn5 定稿):固定 600s 地板——延迟与长生成静默时长无关(三连误杀实证),不再读健康表缩放
|
|
2309
|
-
if (row.zeroGrowthRounds >= HUNG_ZERO_GROWTH_ROUNDS && since && Date.now() - since >= HUNG_WALL_FLOOR_MS) hung.push(rec)
|
|
2310
|
-
} catch (e) { console.error('[task-board] 挂死检测判定失败 (run ' + (rec && rec.id) + '):', String(e)) }
|
|
2311
|
-
})
|
|
2312
|
-
return any ? d : null // 无改动返回 null → 不写盘(安静板纪律;有活跃 run 时主锁本就每轮写池快照,增量零额外写)
|
|
2313
|
-
}, true) // skipKick:本轮已是 poolCycle;判死结算自会 kick(settleWorker 尾部)
|
|
2314
|
-
// ⑥ 判死结算在锁外:失败臂(记「挂死零产出」)+ 熔断连击 + 卡重派。errText 避开冷却嗅探关键词,
|
|
2315
|
-
// 挂死只进连击不进冷却(settleWorker 失败臂 sniffModelCooldown 对该文本零命中)。
|
|
2316
|
-
var killed = 0
|
|
2317
|
-
for (var hi = 0; hi < hung.length; hi++) {
|
|
2318
|
-
var hrec = hung[hi]
|
|
2319
|
-
try {
|
|
2320
|
-
if (hrec.settled || runsFor(sid)[hrec.taskId] !== hrec) continue // 结算通道已抢先,不重复判
|
|
2321
|
-
killed++
|
|
2322
|
-
// 判死文案带出实际生效阈值(task-mvrt9tn5):固定值直述「阈值 600s」——不引用健康表延迟,
|
|
2323
|
-
// 也不再有「3×0s」假精确 / 「地板值,无实测延迟」分态(缩放已删,阈值对任何模型同值)。
|
|
2324
|
-
var wallTxt = Math.round(HUNG_WALL_FLOOR_MS / 1000) + 's'
|
|
2325
|
-
// 帧路径只换诊断文案;settleRun 失败臂、HUNG_KILL_PREFIX、熔断与重派链全部复用。
|
|
2326
|
-
var why = hungWhy[String(hrec.id)] || (HUNG_KILL_PREFIX + '连续 ' + HUNG_ZERO_GROWTH_ROUNDS + ' 轮会话日志零增长(零产出),超挂死墙钟阈值 ' + wallTxt + ',判挂死按模型面故障结算重派')
|
|
2327
|
-
console.log('[task-board] 挂死零产出判死(' + sid + '):task ' + hrec.taskId + ' run ' + hrec.id + ' ' + why)
|
|
2328
|
-
// err 传裸字符串(settleRun 只做 !!err / String(err)):new Error 会被 String(err) 包上
|
|
2329
|
-
// 'Error: ' 前缀,isModelKillFailure 的前缀锚定(index 0)失效 → 熔断连击漏喂。
|
|
2330
|
-
await settleRun(sid, hrec, null, why)
|
|
2331
|
-
} catch (e) { console.error('[task-board] 挂死判死结算失败 (task ' + hrec.taskId + '):', String(e)) }
|
|
2332
|
-
}
|
|
2333
|
-
return killed
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
// ===== 幽灵占位回收(池冻结根修,task-muwlepg6)=====
|
|
2337
|
-
// 病根(活体实证):活跃表项(rec)的**唯一**摘除点是各结算通道(事件通道 settleRun /
|
|
2338
|
-
// 上报通道 settleReportedRun / 手动终止)。任何一条漏走(worker 经 board_report 完成但当时
|
|
2339
|
-
// 还没有上报通道接线、伪 idle 被 rec.ran 守卫挡下、冷窗口、插件热重载丢回调)都会让 rec
|
|
2340
|
-
// 永久留在 state.activeRuns 里,而它同时是四个判定的输入:
|
|
2341
|
-
// ① activeW → capW = max(0, maxWorkers - activeW):残留数 ≥ maxWorkers 时 capW 恒 0 →
|
|
2342
|
-
// **整池 Worker 自动派发全停**(实测板:5 个残留 + 1 个真在跑 → activeW=6 > maxWorkers=3 → capW=0,
|
|
2343
|
-
// 9 张 pending 卡一张都派不出去);
|
|
2344
|
-
// ② pickDispatch 的 busyTaskIds(= 本表)→ 该卡自己永远进不了 Verifier 派发(verifying 卡永不 spawn);
|
|
2345
|
-
// ③ isOrphan 的 !runs[t.id] → 该卡永不被孤儿回收(in-progress 死卡持着 touches 锁堵住后续卡);
|
|
2346
|
-
// ④ poolStatus 快照 → UI 上「幽灵 Worker 卡 busy」。
|
|
2347
|
-
// 四者叠加 = 「poolCycle 整体冻结,force dispatch-task 手动通道却正常」——手动通道
|
|
2348
|
-
// (rpc dispatch-task → spawnOneShot)不经 capW / busyTaskIds 闸门,故症状完全吻合。
|
|
2349
|
-
// 修法:不动任何结算语义,只做**表项 GC**——每轮 cycle 在算活跃度之前,用「卡面证据」核对每个 rec:
|
|
2350
|
-
// 卡已不存在、或卡的状态已不可能是该 rec 的在跑态(worker 不在 in-progress / verifier 不在 verifying /
|
|
2351
|
-
// hook 状态不是 running)、或卡的认领位已换成别的 run id → 该 rec 已失效,立刻摘除。
|
|
2352
|
-
// 摘除是**同步**的(先删表项,本轮 activeW/busyTaskIds 立刻干净、派发当轮恢复),随后用与两条
|
|
2353
|
-
// 结算通道**同一个** settleRunRecord 补一次收尾(结局 / usage / 摘超时臂,三者各自幂等)——
|
|
2354
|
-
// 不另立第二套口径,也不重复记账(usageRecorded 旗与「只关还开着的条目」双保险)。
|
|
2355
|
-
// 边界(宁少勿多,绝不动真在跑的表项):
|
|
2356
|
-
// · 卡面读不到(__noPersist 空板 / tasks 非数组)→ 整段跳过:瞬时读盘失败不能把真 Worker 当幽灵杀;
|
|
2357
|
-
// · 只认卡面证据,**不认** rec.settled——settled=true 的表项可能正被结算通道认领中
|
|
2358
|
-
// (onAgentStatus 先立旗、同一次同步块里才摘表项),此刻抢先摘除会让 settleRun 的认领守卫
|
|
2359
|
-
// 早退、把这一轮的交付物整份丢掉;
|
|
2360
|
-
// · 单条判定/收尾各自 try/catch:一条脏数据只影响该条,不拖死本轮其它回收与派发。
|
|
2361
|
-
function recIsLive(t, rec) {
|
|
2362
|
-
if (!t || !rec) return false // 卡已不存在(板被重建/换工作区)→ 表项无主
|
|
2363
|
-
if (rec.role === 'worker') {
|
|
2364
|
-
// Worker 只在 in-progress 活着;claimedBy 为空 = 占位没落座/已被清(僵尸态)
|
|
2365
|
-
if (t.status !== 'in-progress' || !t.claimedBy) return false
|
|
2366
|
-
if (t.claimedBy !== 'spawn-pending' && String(t.claimedBy) !== String(rec.id)) return false // 认领位已换人(terminate 后重派)
|
|
2367
|
-
return true
|
|
2368
|
-
}
|
|
2369
|
-
if (rec.role === 'verifier') {
|
|
2370
|
-
if (t.status !== 'verifying') return false
|
|
2371
|
-
if (t.verifierRun && t.verifierRun !== 'spawn-pending' && String(t.verifierRun) !== String(rec.id)) return false
|
|
2372
|
-
return true
|
|
2373
|
-
}
|
|
2374
|
-
if (rec.role === 'hook-pre') return t.status === 'in-progress' && hookOn(t, 'pre') && t.hooks.pre.state === 'running'
|
|
2375
|
-
if (rec.role === 'hook-post') return t.status === 'in-progress' && hookOn(t, 'post') && t.hooks.post.state === 'running'
|
|
2376
|
-
// 拆解官(task-mvnenrwk):挂在 draft/pending 源卡上且全程不动卡面状态——源卡仍在草稿/待办即真在跑;
|
|
2377
|
-
// 卡被删除/翻走(如人工强制派发进 in-progress)→ 表项失效,按幽灵回收(迟到结算被 settled 旗挡下)。
|
|
2378
|
-
if (rec.role === 'decomposer') return t.status === 'draft' || t.status === 'pending'
|
|
2379
|
-
// 未知角色(前向兼容):只要卡还在跑态就留着,等卡落定后由这里回收——不在未知形态上做激进判定
|
|
2380
|
-
return t.status === 'in-progress' || t.status === 'verifying'
|
|
2381
|
-
}
|
|
2382
|
-
// 返回本轮摘除条数(调用方据此决定要不要重读盘——收尾写盘会让本轮快照过期)
|
|
2383
|
-
async function reapGhostRecs(sid, snap) {
|
|
2384
|
-
if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0
|
|
2385
|
-
var runs = runsFor(sid)
|
|
2386
|
-
var tasks = snap.tasks
|
|
2387
|
-
var reaped = []
|
|
2388
|
-
Object.keys(runs).forEach(function (k) {
|
|
2389
|
-
var rec = runs[k]
|
|
2390
|
-
if (!rec) return
|
|
2391
|
-
try {
|
|
2392
|
-
var t = null
|
|
2393
|
-
for (var i = 0; i < tasks.length; i++) { if (tasks[i] && tasks[i].id === rec.taskId) { t = tasks[i]; break } }
|
|
2394
|
-
if (recIsLive(t, rec)) return
|
|
2395
|
-
delete runs[k] // 先摘表项:本轮 activeW / busyTaskIds / poolStatus 立刻不再被它污染
|
|
2396
|
-
try { if (typeof rec.disarm === 'function') rec.disarm() } catch (_) {} // 摘两级超时臂(幂等)
|
|
2397
|
-
rec.settled = true // 堵住迟到的结算通道(settleRun 的认领守卫会早退,不会二次推进任务)
|
|
2398
|
-
// 结局口径:卡已落定(resolved/archived/cancelled)或已交验收(verifying)→ 这轮 Worker 交付
|
|
2399
|
-
// 确实完成了;卡还在跑态但认领位换了人/卡丢了 → 结局不可知,记 incomplete(与一次性路径的
|
|
2400
|
-
// 「无 err 的失败」同口径,且据此保留续跑资格由重派路径判断)。
|
|
2401
|
-
var closed = t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled' || t.status === 'verifying'
|
|
2402
|
-
reaped.push({ rec: rec, outcome: closed ? 'completed' : 'incomplete' })
|
|
2403
|
-
} catch (e) { console.error('[task-board] 幽灵表项判定失败(本轮跳过该条):', String(e)) }
|
|
2404
|
-
})
|
|
2405
|
-
if (!reaped.length) return 0
|
|
2406
|
-
for (var r = 0; r < reaped.length; r++) {
|
|
2407
|
-
try { await settleRunRecord(sid, reaped[r].rec, { outcome: reaped[r].outcome }) }
|
|
2408
|
-
catch (e) { console.error('[task-board] 幽灵表项收尾失败 (task ' + reaped[r].rec.taskId + '):', String(e)) }
|
|
2409
|
-
}
|
|
2410
|
-
console.log('[task-board] 回收失效活跃表项(幽灵占位)' + reaped.length + ' 个:' + reaped.map(function (x) { return String(x.rec.taskId) + '/' + String(x.rec.role) + '/' + x.outcome }).join(', '))
|
|
2411
|
-
return reaped.length
|
|
2412
|
-
}
|
|
964
|
+
// 重启 reconcile(reconcileRoot)/ 僵尸留档清扫(sweepZombieRuns)/ 幽灵占位回收(recIsLive/reapGhostRecs)
|
|
965
|
+
// 已拆至 lib/settle.mjs(拆分第 7 刀 task-mvt8c1yx-7):poolCycleBody 三个挂点改经 settle 实例调用
|
|
966
|
+
// (reconcile 域闭包 deps withTimeout/armTimeouts/logSizeOf 随工厂入参注入,dispatchedEver 由 state 别名同源)。
|
|
967
|
+
// 挂死零产出检测(hungZeroOutputSweep)已拆至 lib/hung.mjs(拆分第 1 刀 task-mvt8c1yx),poolCycleBody 改经 hung.hungZeroOutputSweep 调用。
|
|
2413
968
|
|
|
2414
969
|
// ===== 派发周期(15s 心跳 + 写入后 kickCycle 触发)=====
|
|
2415
970
|
// 结构(池冻结防御,task-muwlepg6):入口 root 闸门 → 读盘 → 本轮主体(poolCycleBody)整段包 try。
|
|
@@ -2448,16 +1003,16 @@ export function createDispatch(ctx, state, deps) {
|
|
|
2448
1003
|
// 重启 reconcile(卡3 Step2):只在本 host 生命周期的首轮 cycle 做一次(内部有 reconcileDone 标记)。
|
|
2449
1004
|
// 位置取舍:root 闸门必须是第一件事(无活 root 的板零 IO),所以本段只能排在早退闸门与 rt 读盘之后;
|
|
2450
1005
|
// 而重建出来的 rec 仍在本轮被读到(活跃计数 / 池状态快照都在下面才求值),语义不受影响。
|
|
2451
|
-
try { await reconcileRoot(sid) } catch (e) { console.error('[task-board] reconcile 失败:', String(e)) }
|
|
1006
|
+
try { await settle.reconcileRoot(sid) } catch (e) { console.error('[task-board] reconcile 失败:', String(e)) }
|
|
2452
1007
|
// 僵尸 run 留档清扫(task-mv22z61v):位置在 reconcile 之后、幽灵回收与空闲快进之前。
|
|
2453
1008
|
// 落定卡的僵尸留档无活跃 rec,reconcile(只在飞卡)/reapGhostRecs(只清内存表项)都管不到它,
|
|
2454
1009
|
// 而它若留在 running 会卡死 archive;安静板(只有已落定卡)会走空闲快进早退,清扫必须排在其前才能兜到。
|
|
2455
|
-
try { await sweepZombieRuns(sid, snap) } catch (e) { console.error('[task-board] 僵尸留档清扫失败:', String(e)) }
|
|
1010
|
+
try { await settle.sweepZombieRuns(sid, snap) } catch (e) { console.error('[task-board] 僵尸留档清扫失败:', String(e)) }
|
|
2456
1011
|
// 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
|
|
2457
1012
|
// 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
|
|
2458
1013
|
// 故有回收时重读一次快照——否则下面空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(写盘已归一进锁)。
|
|
2459
1014
|
var reapedN = 0
|
|
2460
|
-
try { reapedN = await reapGhostRecs(sid, snap) } catch (e) { console.error('[task-board] 幽灵占位回收失败(本轮跳过,派发照常):', String(e)) }
|
|
1015
|
+
try { reapedN = await settle.reapGhostRecs(sid, snap) } catch (e) { console.error('[task-board] 幽灵占位回收失败(本轮跳过,派发照常):', String(e)) }
|
|
2461
1016
|
if (reapedN > 0) {
|
|
2462
1017
|
try { snap = await rt(sid) } catch (_) {}
|
|
2463
1018
|
// ③ 幽灵回收可视化(运行时健康自检):本轮回收 >0 留一条一次性记录(90s 保鲜),
|
|
@@ -2469,7 +1024,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
2469
1024
|
// 卡面由失败臂翻回 pending,同一轮即可重派;判死结算会写盘(关账/落卡),有判死时重读快照——
|
|
2470
1025
|
// 否则下方空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(与幽灵回收同款纪律)。
|
|
2471
1026
|
var hungN = 0
|
|
2472
|
-
try { hungN = await hungZeroOutputSweep(sid, runs, snap) } catch (e) { console.error('[task-board] 挂死零产出检测失败(本轮跳过):', String(e)) }
|
|
1027
|
+
try { hungN = await hung.hungZeroOutputSweep(sid, runs, snap) } catch (e) { console.error('[task-board] 挂死零产出检测失败(本轮跳过):', String(e)) }
|
|
2473
1028
|
if (hungN > 0) { try { snap = await rt(sid) } catch (_) {} }
|
|
2474
1029
|
var activeW = 0, activeV = 0
|
|
2475
1030
|
// 角色口径:worker 计入 activeW(占 Worker 并发位);verifier 与 hook run(hook-pre/hook-post)
|
|
@@ -2751,8 +1306,10 @@ export function createDispatch(ctx, state, deps) {
|
|
|
2751
1306
|
// state.activeRuns = {} 等价)。原位清空是必要的——runsFor(sid) 返回的是每会话子对象,
|
|
2752
1307
|
// 只换顶层容器会把这些子对象连同里面的 rec 一起漏掉。
|
|
2753
1308
|
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 = {} } })
|
|
2754
|
-
//
|
|
2755
|
-
|
|
1309
|
+
// 卸载同时停主动嗅探(拆分第 4 刀重指):注册仍在本侧执行,回调只调 health 工厂暴露的 disposeProbe()
|
|
1310
|
+
// (AbortSignal 取消在飞请求 + 清 probeNextAt 间隔闸表 + 落 probeDisposed 卸载闸,三步操作逐字一致;
|
|
1311
|
+
// 迟到结果不再写健康表,已有被动表随 host 状态一并废弃)。
|
|
1312
|
+
ctx.effect(function () { return function () { disposeProbe() } })
|
|
2756
1313
|
// Host 侧调度心跳:每 15s 对所有已知会话跑 poolCycle(客户端轮询只是触发器之一,面板关闭/后台节流时照常运转)
|
|
2757
1314
|
;(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 }) })()
|
|
2758
1315
|
|
|
@@ -2794,5 +1351,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
2794
1351
|
|
|
2795
1352
|
// resumeTarget 一并透出(task-mv6c7cbo):rpc 侧 doIntervene 在无活跃 rec 时靠它细分
|
|
2796
1353
|
// 「续跑窗口 / 真无 run」两态(纯读 t.runs 零 IO,同一函数两处复用,不另写第二套口径)。
|
|
2797
|
-
return
|
|
1354
|
+
// return API(拆分第 7 刀):accumulateRunUsage 随源码搬至 settle.mjs(工厂内直调),key 名不变、
|
|
1355
|
+
// 懒绑定重指 settle 实例(上方已接线;rpc 侧手动终止关账消费面零变化)。
|
|
1356
|
+
return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: function () { return settle.accumulateRunUsage.apply(null, arguments) }, settleReportedRun: settle.settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory, resumeTarget: resumeTarget, probeConfiguredModels: probeConfiguredModels, maybeProbeModels: maybeProbeModels }
|
|
2798
1357
|
}
|