dsh-agent-board 1.9.0 → 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/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 { readRunUsage, findRunLog, readLogBytes, readLogFrames } from './usage.mjs'
9
- import { splitRuleOf, pushRejectLesson, DECOMPOSE_SCHEMA, buildDecomposerPrompt, applyDecomposeProposal, TEAM_DECOMPOSE_FLOW } from './policy.mjs'
10
- const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, lateNoticeFp, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER, opsLogLine } = core
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
- // 纯函数:两类报错形态识别,返回 { until, parsed, kind },均不命中返回 null。
14
- // ① 限流形态(kind='rate-limit',行为与 C5 落地逐字一致):命中 /429|RATE_LIMIT|rate.?limit|限额|额度/i;
15
- // 尝试解析「YYYY-MM-DD HH:mm:ss 重置」(中文,glm 原形事故实证形态)或「reset(s) at/on YYYY-MM-DD HH:mm:ss」
16
- // (英文)样式时间 → until 时间戳(按本地时区构造,与供应商报错文案口径一致);解析不到时间 →
17
- // until = now + 30min(保守缺省)。
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,行为不变。
@@ -61,218 +52,47 @@ export function createDispatch(ctx, state, deps) {
61
52
  var notifyLateApprove = (typeof deps.notifyLateApprove === 'function') ? deps.notifyLateApprove : null // 迟到复核 notice 级通知(三连根修②,未接线 → 静默跳过)
62
53
  // 派发即回执:spawn 成功后入 45s 聚合队列的「🚀 已派发」区(老 host 未注入 → 静默跳过)
63
54
  var notifyDispatched = deps.notifyDispatched
55
+ // 拆解官回执(task-mvrqiu6x):拆解官落定 split/keep → 「🧩 已拆解」聚合分区(老 host 未注入 → 静默跳过)
56
+ var notifyDecomposed = deps.notifyDecomposed
64
57
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
65
58
  var dispatchedEver = state.dispatchedEver
66
59
  var badModels = state.badModels
67
- // 冷却表(task-mvneq4kk 限流 / task-mvnx8hnm 连接故障):与 badModels 同生命周期(纯内存不持久化,
68
- // 重启即清——额度重置窗口本就短,连接短窗 10min 更短)。语义分治:badModels=连续失败熔断(布尔),
69
- // modelCooldown=窗口冷却(until 时间戳),两张表各管各的(连接类冷却只进本表,不动熔断判定)。
70
- // key = 模型 id(provider/model 复合串):限流是账户级额度窗口、连接故障是网关面,都不按会话分键。
71
- // 缺字段就地补(测试桩/老宿主兼容,与 poolHealthFor 同例)。
72
- var modelCooldown = state.modelCooldown || (state.modelCooldown = {})
73
- // 统一健康表(task-mvoulhm7):内部按会话分桶,避免 sid|model 熔断信号串板;get-tasks 公开为 {modelId:entry}。
74
- // observed=最近主动探测/run 的基础状态,冷却与熔断是覆盖态;调度仍只认这两种覆盖态,未知不等于不可用。
75
- var modelHealth = state.modelHealth || (state.modelHealth = {})
76
- // 熔断连击表(task-mvowb2hc 洞①):sid|model → 连续「模型面失败」次数(纯内存不持久化,与
77
- // badModels/modelCooldown 同生命周期,重启即清)。语义:只有「会话被宿主/激活机制在开工前杀掉」
78
- // 这类模型面失败才计数(worker 自己的失误——空输出/超时等——不算),连续 N 次才拉黑进 badModels
79
- // (熔断语义不变:拉黑后链跳过 + 健康表 circuit);任一可归因 run 正常完成即归零(settleRunRecord)。
80
- var modelFailStreak = state.modelFailStreak || (state.modelFailStreak = {})
81
- var probeInFlight = {}, probeAbort = {}, probeNextAt = {}, probeDisposed = false
82
- function healthBucket(sid) { return modelHealth[sid] || (modelHealth[sid] = {}) }
83
- function healthEntry(sid, model) {
84
- var b = healthBucket(sid)
85
- return b[model] || (b[model] = { state: 'unknown', observed: 'unknown', lastProbe: null, lastSeen: null, note: '尚无健康信号', observedNote: '尚无健康信号', recentRuns: [] })
86
- }
87
- function syncModelHealth(sid, model, cooldownOverride) {
88
- var e = healthEntry(sid, model), until = typeof cooldownOverride === 'number' ? cooldownOverride : cooldownLive(model)
89
- e.state = e.observed === 'healthy' ? 'healthy' : 'unknown'
90
- e.note = e.observedNote || '尚无健康信号'
91
- delete e.until
92
- if (until) { e.state = 'cooldown'; e.until = until; e.note = e.cooldownNote || '模型冷却中' }
93
- if (badModels[modelKey(sid, model)]) { e.state = 'circuit'; e.note = e.circuitNote || '连续失败,模型熔断中' }
94
- return e
95
- }
96
- function observeModel(sid, model, ok, opts) {
97
- if (!model || probeDisposed) return null
98
- opts = opts || {}
99
- var e = healthEntry(sid, model), at = new Date().toISOString()
100
- var latency = Number(opts.latencyMs)
101
- e.lastSeen = at
102
- e.observed = ok ? 'healthy' : 'unknown'
103
- if (isFinite(latency) && latency >= 0) e.latencyMs = Math.round(latency)
104
- if (opts.probe) {
105
- e.lastProbe = at
106
- e.observedNote = ok ? '主动探测通过' : ('主动探测失败' + (opts.note ? ':' + opts.note : ''))
107
- } else {
108
- e.recentRuns.push({ ok: !!ok, at: at, latencyMs: isFinite(latency) && latency >= 0 ? Math.round(latency) : null })
109
- if (e.recentRuns.length > 5) e.recentRuns = e.recentRuns.slice(-5)
110
- var passed = e.recentRuns.filter(function (r) { return r.ok }).length
111
- e.observedNote = (ok ? '最近 run 成功' : '最近 run 失败') + '(近 ' + e.recentRuns.length + ' 次 ' + passed + ' 次成功)'
112
- }
113
- e.note = e.observedNote
114
- return syncModelHealth(sid, model)
115
- }
116
- function markModelCircuit(sid, model, note) {
117
- var e = healthEntry(sid, model)
118
- e.circuitNote = note || '连续验收失败,模型熔断中'
119
- return syncModelHealth(sid, model)
120
- }
121
- // 冷却期判定:返回冷却截止 ts(0=不在冷却);过期项惰性清除(读路径顺手删,无需 sweeper)
122
- function cooldownLive(model) {
123
- var until = modelCooldown[model]
124
- if (!until) return 0
125
- if (Date.now() >= until) { delete modelCooldown[model]; return 0 }
126
- return until
127
- }
128
- // 冷却嗅探登记:两条登记路径共用(①spawn extras 回退臂 e/e2 文本 ②settleWorker 失败臂 errText——
129
- // activation.result 拆除兜底臂带上来的诊断文本也走②)。max 收敛:多次报错只延不缩
130
- // (带重置时间的精确值与 30min/10min 缺省并存时取更晚者);截止未后延则零改动零留痕(防重复报错刷屏)。
131
- // 留痕分态(task-mvnx8hnm):限流=「额度冷却至 HH:mm」,连接/网关=「连接故障短冷却 10 分钟」——
132
- // 卡 messages 各记一条 kind='cooldown',排查时一眼分清「额度没了」与「连不上」。
133
- // 写卡失败静默吞掉(留痕是附属动作,绝不阻断 spawn 回退/失败结算主流程)。返回冷却截止 ts,未命中 null。
134
- async function sniffModelCooldown(sid, taskId, model, errText) {
135
- if (!model) return null
136
- var hit = parseModelCooldown(errText)
137
- if (!hit) return null
138
- if (hit.until <= (modelCooldown[model] || 0)) { syncModelHealth(sid, model, modelCooldown[model]); return modelCooldown[model] }
139
- modelCooldown[model] = hit.until
140
- var until = modelCooldown[model]
141
- var he = healthEntry(sid, model)
142
- he.cooldownNote = hit.kind === 'connectivity' ? ('连接/网关故障,冷却至 ' + hhmmOf(until)) : ('额度限流,冷却至 ' + hhmmOf(until))
143
- syncModelHealth(sid, model, until)
144
- if (hit.kind === 'connectivity') console.error('[task-board] 模型 ' + model + ' 连接/网关故障,短冷却 10 分钟至 ' + new Date(until).toISOString())
145
- else console.error('[task-board] 模型 ' + model + ' 命中 RATE_LIMIT 限流,冷却至 ' + new Date(until).toISOString() + '(' + (hit.parsed ? '重置时间解析' : '缺省 30min') + ')')
146
- try {
147
- await mutateLocked(sid, function (d) {
148
- var t = d.tasks.find(function (x) { return x.id === taskId })
149
- if (!t) return null
150
- if (!Array.isArray(t.messages)) t.messages = []
151
- t.messages.push({ kind: 'cooldown', text: hit.kind === 'connectivity' ? ('🔌 模型 ' + model + ' 连接故障短冷却 10 分钟(至 ' + hhmmOf(until) + ')') : ('⏳ 模型 ' + model + ' 额度冷却至 ' + hhmmOf(until)), at: new Date().toISOString(), by: 'system' })
152
- return { ok: true }
153
- }, true) // skipKick:只是留痕,不踢 poolCycle
154
- } catch (_) {}
155
- return until
156
- }
157
- // ===== 备用模型链健康判定(task-mvnqxlpn):不在 badModels 熔断名单且不在 modelCooldown 冷却期 =====
158
- // 两张表各管各的(熔断=连续失败布尔,冷却=额度窗口时间戳),任一命中即不健康。
159
- // 消费点=spawnOneShot 取模链:候选被判不健康时先扫板级 fallbackModels 找首个健康者(链本身逐项过本检查)。
160
- function healthyModel(sid, modelId) {
161
- if (!modelId) return false
162
- var h = syncModelHealth(sid, modelId)
163
- if (h.state === 'circuit') return false
164
- if (h.state === 'cooldown') return false
165
- return true
166
- }
167
- // 扫备用链找首个健康模型(保持链序=主窗口配置的能力水位优先级);链尽/空链返回 ''。
168
- function pickHealthyFallback(sid, fbList) {
169
- if (!Array.isArray(fbList)) return ''
170
- for (var fi = 0; fi < fbList.length; fi++) {
171
- if (healthyModel(sid, fbList[fi])) return fbList[fi]
172
- }
173
- return ''
174
- }
175
- // ===== 熔断连击喂给(task-mvowb2hc 洞①):模型面失败判别 + 连击计数 + 三连拉黑 =====
176
- // 模型面失败 = 会话被宿主/激活机制在开工前杀掉,两条来源文本都在本文件内确定:
177
- // ① activation.result 拆除兜底臂:「子会话被宿主拆除(stopReason: X,…)」(stopReason=completed
178
- // 的收尾性拆除不算——那是正常完成的副产物,不是模型失败);
179
- // ② activation 异常兜底臂:「子会话 activation 异常:…」(spawn 机制/provider 面失败,同属开工前)。
180
- // worker 自己的失误(空输出/超时中断/文本降级失败)不算——好模型不该为 worker 的错误背锅。
181
- var WORKER_MODEL_CIRCUIT_AFTER = 3 // 连续 N 次模型面失败才拉黑(与 worker 三连败阻塞同一三击口径)
182
- function isModelKillFailure(errText) {
183
- var s = String(errText || '')
184
- if (s.indexOf('子会话被宿主拆除(stopReason: ') === 0) return s.indexOf('子会话被宿主拆除(stopReason: completed') !== 0
185
- return s.indexOf('子会话 activation 异常:') === 0
186
- }
187
- // 连击 +1;达 N 且尚未拉黑 → badModels 落黑名单(取模链/降档候选的既有熔断判定照常消费,零新分支)
188
- // + 健康表 circuit + 系统通知(不带 taskId:与验收熔断通知同口径,不受任务回执冲刷丢弃)。
189
- // 返回是否本次调用触发拉黑(测试/留痕用)。
190
- function feedModelFailure(sid, model) {
191
- if (!model) return false
192
- var k = modelKey(sid, model)
193
- var n = (modelFailStreak[k] || 0) + 1
194
- modelFailStreak[k] = n
195
- if (n < WORKER_MODEL_CIRCUIT_AFTER) return false
196
- if (!badModels[k]) {
197
- badModels[k] = true
198
- markModelCircuit(sid, model, '连续 ' + n + ' 次 spawn/续跑即被宿主拆除(模型失败),已熔断')
199
- pushSysNote(sid, '模型 ' + model + ' 连续 ' + n + ' 次 spawn/续跑即被宿主秒拆(模型失败),已熔断——后续取模链跳过该模型')
200
- }
201
- return true
202
- }
203
- // 连击归零:任一可归因 run 正常完成即证明模型链路恢复(settleRunRecord completed 路径调用)
204
- function resetModelFailure(sid, model) { if (model) delete modelFailStreak[modelKey(sid, model)] }
205
- // ===== 主动模型嗅探:llm.stream 极短直调(无子代理/系统提示/工具,maxTokens=1)=====
206
- // finish=stop/max-tokens 等均证明模型链路可用;error/aborted/无 finish 才算失败。失败文本复用既有冷却登记,
207
- // 但不改 badModels 熔断判定。同模型全局只在飞一次,15s 硬超时带 AbortSignal,绝不阻塞 poolCycle 派发。
208
- async function probeModel(sid, model) {
209
- if (probeDisposed) return null
210
- if (probeInFlight[model]) return probeInFlight[model]
211
- var llm = ctx.get && ctx.get('llm')
212
- if (!llm || typeof llm.stream !== 'function') return null
213
- var slash = model.indexOf('/')
214
- if (slash <= 0 || slash === model.length - 1) return null
215
- var started = Date.now(), ac = new AbortController(), timedOut = false, disposeTimer = null
216
- var promise = (async function () {
217
- try {
218
- if (ctx.timer && typeof ctx.timer.timeout === 'function') disposeTimer = ctx.timer.timeout(function () { timedOut = true; ac.abort() }, 15000)
219
- else { var nativeTimer = setTimeout(function () { timedOut = true; ac.abort() }, 15000); disposeTimer = function () { clearTimeout(nativeTimer) } }
220
- var stream = llm.stream({ provider: model.slice(0, slash), model: model.slice(slash + 1), messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], maxTokens: 1, signal: ac.signal })
221
- var finished = false, failure = ''
222
- for await (var chunk of stream) {
223
- if (!chunk || chunk.type !== 'finish') continue
224
- finished = true
225
- var reason = chunk.reason || {}
226
- if (reason.kind === 'error' || reason.kind === 'aborted') failure = String((reason.failure && reason.failure.message) || reason.kind || '模型探测失败')
227
- }
228
- if (!finished) throw new Error('模型探测流未返回 finish')
229
- if (failure) throw new Error(failure)
230
- observeModel(sid, model, true, { probe: true, latencyMs: Date.now() - started })
231
- return true
232
- } catch (e) {
233
- var text = timedOut ? 'probe timeout 15000ms' : String(e && e.message || e).slice(0, 300)
234
- observeModel(sid, model, false, { probe: true, latencyMs: Date.now() - started, note: text })
235
- if (!probeDisposed) { try { await sniffModelCooldown(sid, '', model, text) } catch (_) {} }
236
- return false
237
- } finally {
238
- try { if (typeof disposeTimer === 'function') disposeTimer() } catch (_) {}
239
- delete probeInFlight[model]
240
- delete probeAbort[model]
241
- }
242
- })()
243
- probeInFlight[model] = promise
244
- probeAbort[model] = ac
245
- return promise
246
- }
247
- async function probeConfiguredModels(sid, d) {
248
- var c = cfg(d), models = core.configuredModelIds(d)
249
- for (var i = 0; i < models.length; i++) syncModelHealth(sid, models[i])
250
- if (!c.probeEnabled) return { disabled: true, models: models, probed: 0 }
251
- var llm = ctx.get && ctx.get('llm')
252
- if (!llm || typeof llm.stream !== 'function') return { disabled: false, unavailable: true, models: models, probed: 0 }
253
- var probed = 0, healthy = 0
254
- for (var m = 0; m < models.length; m++) {
255
- if (probeDisposed) break
256
- var before = probeInFlight[models[m]] ? false : true
257
- var ok = await probeModel(sid, models[m])
258
- if (ok === null) continue
259
- if (before) probed++
260
- if (ok === true) healthy++
261
- }
262
- return { disabled: false, models: models, probed: probed, healthy: healthy }
263
- }
264
- // 15s 调度心跳驱动间隔闸:配置变化立即重排,探测在后台跑,不 await 进派发主链。
265
- function maybeProbeModels(sid, d) {
266
- var c = cfg(d), now = Date.now()
267
- if (!c.probeEnabled) { probeNextAt[sid] = 0; return }
268
- var llm = ctx.get && ctx.get('llm')
269
- if (!llm || typeof llm.stream !== 'function') return
270
- var planKey = String(c.probeIntervalMin)
271
- if (probeNextAt[sid] && probeNextAt[sid].plan === planKey && now < probeNextAt[sid].at) return
272
- if (!probeNextAt[sid] || probeNextAt[sid].plan !== planKey) probeNextAt[sid] = { at: 0, plan: planKey }
273
- probeNextAt[sid] = { at: now + c.probeIntervalMin * 60000, plan: planKey }
274
- probeConfiguredModels(sid, d).catch(function (e) { console.error('[task-board] 模型健康探测失败(下周期重试):', String(e)) })
275
- }
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 回调(文件尾)。
276
96
  // 「上下文注入」区块通道的按子代理会话缓存表 + 首轮竞速认领队列随该通道一起退役(task-muvjs392):
277
97
  // 瘦身清单直接进首条 prompt,不再需要注入段命中与父子归属认领(并行 spawn 认领错包的潜伏 bug 一并消灭)。
278
98
  var knownSessions = state.knownSessions
@@ -302,7 +122,8 @@ export function createDispatch(ctx, state, deps) {
302
122
  // activeRuns / dispatchedEver 容器本体在 state(index.mjs 统一构建);runsFor/isDispatched 在 session 层
303
123
 
304
124
  // 模型熔断:带覆盖模型的 run 若立即失败(如 UNKNOWN_MODEL——模型在当前网关没配置),记入坏名单回退父级
305
- function modelKey(sid, model) { return sid + '|' + model }
125
+ // modelKey(sid|model 复合键)已拆至 lib/model-health.mjs(拆分第 2 刀 task-mvt8c1yx-2,单源导出——
126
+ // 熔断名单与健康表熔断判定共用同一键式,上方 import 同源使用)。
306
127
 
307
128
  var _cachedProvider = null
308
129
  function pickProvider() {
@@ -541,6 +362,10 @@ export function createDispatch(ctx, state, deps) {
541
362
  var dsnap = await rt(sid)
542
363
  if (role === 'worker' || role === 'verifier') {
543
364
  var roleKey = role === 'verifier' ? 'verifier' : 'worker'
365
+ // 目录失效闸数据源(task-mvd3li6):取一次当前模型目录(llm.listProviders+listModels,与 rpc enumModels
366
+ // 同源);null=目录不可得(llm 缺失老宿主/枚举异常/空目录)→ 目录闸整体跳过不误伤(保守语义③,
367
+ // 既有熔断/冷却/连败软闸照常生效,行为不因宿主能力缺失而变)。
368
+ var dir = await modelDirectory()
544
369
  var cands = []
545
370
  var tm = typeof t.model === 'string' ? t.model.trim() : ''
546
371
  if (tm) cands.push([tm, 'card'])
@@ -549,12 +374,13 @@ export function createDispatch(ctx, state, deps) {
549
374
  var laneCfg = cfg(dsnap).laneModels
550
375
  if (sz && laneCfg[sz]) {
551
376
  // 档位槽=有序数组(task-mvnxckhq 同档优先序,第一位=首选):槽内按序取第一个**健康**模型
552
- // (healthyModel 双闸:不过 badModels 熔断、不在 modelCooldown 冷却期;偏好序非故障序——
377
+ // (healthyModel 四闸:不过 badModels 熔断、不在 modelCooldown 冷却期、目录可得时不在目录外
378
+ // 【目录失效闸 task-mvd3li6】、近期连败未达软闸线【连败软闸 task-mvd3li6】;偏好序非故障序——
553
379
  // 首选病即次选赢,不降级不绕链)。槽内全病 → 以首项为代表照常入链,由既有熔断/冷却分支
554
380
  // 照旧消费 fallbackModels → 降档 → parent(既有链逐字不动);空数组槽位=不命中,降下一级。
555
381
  var lvList = Array.isArray(laneCfg[sz][roleKey]) ? laneCfg[sz][roleKey] : []
556
382
  var lv = ''
557
- for (var li = 0; li < lvList.length; li++) { if (healthyModel(sid, lvList[li])) { lv = lvList[li]; break } }
383
+ for (var li = 0; li < lvList.length; li++) { if (healthyModel(sid, lvList[li], dir)) { lv = lvList[li]; break } }
558
384
  if (!lv && lvList.length) lv = lvList[0] // 槽内全病:首项代表入链(既有熔断/冷却分支照旧兜底)
559
385
  if (lv) cands.push([lv, 'lane'])
560
386
  }
@@ -568,9 +394,13 @@ export function createDispatch(ctx, state, deps) {
568
394
  // 留痕:命中即 run.modelSource='fallback' + 卡 messages 记「原模型 X 冷却/熔断,切备用 Y」。
569
395
  var fbList = cfg(dsnap).fallbackModels
570
396
  for (var mi = 0; mi < cands.length; mi++) {
397
+ // ===== 目录失效闸(task-mvd3li6):候选不在当前模型目录(已从宿主配置删除/改名)→ 跳过该候选 =====
398
+ // 覆盖卡级特例/档位/板级全路径——卡级特例只豁免健康闸(连败软闸/熔断/冷却),**不豁免存在闸**
399
+ // (原形:本体板已删除的失效模型钉在卡级特例上还在被派)。目录不可得(dir=null)→ 整体跳过不误伤。
400
+ if (outOfDir(cands[mi][0], dir)) { await traceStaleModel(sid, t.id, cands[mi][0]); continue }
571
401
  if (badModels[modelKey(sid, cands[mi][0])]) {
572
402
  // 熔断候选:先扫备用链找首个健康者(链本身逐项过健康检查),命中即切备用(不再降下一跳)
573
- var fbHit = pickHealthyFallback(sid, fbList)
403
+ var fbHit = await pickHealthyFallback(sid, fbList, dir, t.id)
574
404
  if (fbHit) {
575
405
  console.error('[task-board] model ' + cands[mi][0] + ' (' + cands[mi][1] + ' 级) circuited, switch to fallback ' + fbHit)
576
406
  try {
@@ -601,7 +431,7 @@ export function createDispatch(ctx, state, deps) {
601
431
  if (cdUntil) {
602
432
  var cdFrom = modelOverride
603
433
  // 冷却候选:同样先扫备用链找首个健康者,链尽才走 C5 降档链
604
- var fbCd = pickHealthyFallback(sid, fbList)
434
+ var fbCd = await pickHealthyFallback(sid, fbList, dir, t.id)
605
435
  if (fbCd) {
606
436
  modelOverride = fbCd
607
437
  modelSource = 'fallback'
@@ -625,6 +455,7 @@ export function createDispatch(ctx, state, deps) {
625
455
  var dmList = dl && Array.isArray(dl[roleKey]) ? dl[roleKey] : []
626
456
  for (var dmi = 0; dmi < dmList.length; dmi++) {
627
457
  var dm = dmList[dmi]
458
+ if (outOfDir(dm, dir)) { console.error('[task-board] cooldown 降档候选 ' + dm + ' 不在当前模型目录(配置已失效),继续降档'); await traceStaleModel(sid, t.id, dm); continue }
628
459
  if (badModels[modelKey(sid, dm)]) { console.error('[task-board] cooldown 降档候选 ' + dm + ' 熔断中,继续降档'); continue }
629
460
  if (cooldownLive(dm)) { console.error('[task-board] cooldown 降档候选 ' + dm + ' 同在冷却期,继续降档'); continue }
630
461
  cdPicked = dm; break
@@ -767,7 +598,8 @@ export function createDispatch(ctx, state, deps) {
767
598
  // hold 终止据此 dispose 销毁子会话(老契约路径无 activation,act=null,保持 interrupt 留存)。
768
599
  var rec = useContinuable
769
600
  ? { id: childId, childId: childId, continuable: true, ran: false, run: null, act: actForSettle, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, modelSource: modelSource, size: laneSize || undefined, settled: false }
770
- : { id: String(run.id), run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, modelSource: modelSource, size: laneSize || undefined, settled: false }
601
+ // activation 的 run.id=act.childId,可接 assistant-stream;老 start 的 run.id=provider run id,留空走日志闸。
602
+ : { id: String(run.id), frameSessionId: hasActivationApi(subagents) ? String(run.id) : '', run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, modelSource: modelSource, size: laneSize || undefined, settled: false }
771
603
  // ===== 新宿主 continuable 结算兜底臂(v0.2.1 实证 2026-10-24:activation.result 只认拆除落定)=====
772
604
  // 原形:rate-limit 子会话 turn/end error → 管理器拆除 activation → result 落定——
773
605
  // 但 agent/status 事件面在 v0.2.1 的 Scoped 事件体系下对插件全局订阅不再可达(子会话死亡 8 分钟零结算实证),
@@ -775,10 +607,11 @@ export function createDispatch(ctx, state, deps) {
775
607
  // (正常完成 parked-idle 不落定、不误伤续跑)——把它接上就是失败/拆除场景的零事件依赖结算通道。
776
608
  // rec.settled 首胜旗与事件/超时/上报三通道共用:先到先结,后到被挡。
777
609
  // 归属不变式(task-mvnyk55n 驳回根修):「rec 被摘出 runsFor 活跃表 ⇒ 摘除点已同步立
778
- // rec.settled」对全部摘除路径成立(落闸弃权 646 / 续跑弃权 838 / settleRun 各入口
779
- // finish 698·事件 1299 / settleReportedRun 1262 / 幽灵清扫 1735 / doTerminate 摘表即立——
610
+ // rec.settled」对全部摘除路径成立(落闸弃权臂 / 续跑弃权臂 / settleRun 的 finish 臂与事件臂
611
+ // (lib/settle.mjs)/ settleReportedRun(lib/settle.mjs)/ 幽灵清扫 / doTerminate 摘表即立——
780
612
  // 后者正是本次补上的缺口:hold 终止的 dispose 落定 result 时本臂若抢结,会把手动终止
781
613
  // 误记成 Worker 失败)。因此本臂的 settled 判即归属守卫,无需再判表项在否。
614
+ // (各臂按函数名定位;本文件历次拆分后行号持续漂移,此注释不再钉行号。)
782
615
  if (useContinuable && actForSettle && actForSettle.result && typeof actForSettle.result.then === 'function') {
783
616
  actForSettle.result.then(function (res) {
784
617
  if (rec.settled) return
@@ -787,11 +620,12 @@ export function createDispatch(ctx, state, deps) {
787
620
  // diagnostic 截断放宽 120→400(task-mvneq4kk):glm 等供应商的 429 报错「限额将在 YYYY-MM-DD HH:mm:ss
788
621
  // 重置」结构化文本常超 120 字符,截太短会把重置时间切掉 → RATE_LIMIT 嗅探只能退 30min 缺省。
789
622
  var errText = '子会话被宿主拆除(stopReason: ' + stopReason + (res && res.diagnostic ? ',' + String(res.diagnostic).slice(0, 400) : '') + ')'
790
- settleWorker(sid, rec, '', true, errText).catch(function (e) { console.error('[task-board] activation 拆除结算失败 (task ' + rec.taskId + '):', String(e)) })
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)) })
791
625
  }, function (err) {
792
626
  if (rec.settled) return
793
627
  rec.settled = true
794
- 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)) })
795
629
  })
796
630
  }
797
631
  // ⚠️ spawn 返回 → 登记 rec 必须**零 await**(既有纪律):continuable 子会话的 running→idle 事件
@@ -865,7 +699,7 @@ export function createDispatch(ctx, state, deps) {
865
699
  var startedAt = rec.startedAt
866
700
  var softMs = c.softTimeoutMin * 60000
867
701
  var hardMs = c.hardTimeoutMin * 60000
868
- 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) }
869
703
  function disarmTimeouts() {
870
704
  if (rec.timersDisarmed) return
871
705
  rec.timersDisarmed = true
@@ -945,9 +779,14 @@ export function createDispatch(ctx, state, deps) {
945
779
  // 已被熔断/冷却时照样续跑(实证:病模型续跑 38 分钟零产出)。修:续跑前查目标会话模型健康
946
780
  // (badModels 熔断 + modelCooldown 冷却,healthyModel 双闸同款),病模型 → 放弃续跑返回 fallback,
947
781
  // 调用方落回 fresh spawn 走健康取模链(链自动跳过病模型),原因随 resume-fallback 消息留档。
782
+ // task-mvd3li6 起健康面拓宽同款适用:近期连败软闸(recentRuns ≥2 全败)+ 目录失效闸(原模型
783
+ // 已从模型目录移除,续跑也救不回已删模型)一并拦下;目录不可得(dir=null)时目录闸跳过不误伤。
948
784
  var origModel = resumeModelOf(t)
949
- if (origModel && !healthyModel(sid, origModel)) {
950
- return { fallback: '续跑放弃:原模型 ' + origModel + ' 熔断/冷却中(续跑健康闸)——改走健康取模链重跑' }
785
+ if (origModel) {
786
+ var dir = await modelDirectory()
787
+ if (!healthyModel(sid, origModel, dir)) {
788
+ return { fallback: '续跑放弃:原模型 ' + origModel + ' 熔断/冷却/近期连败或配置已失效(续跑健康闸)——改走健康取模链重跑' }
789
+ }
951
790
  }
952
791
  var parent = rootForSession(sid)
953
792
  if (!parent) return { fallback: 'root 会话不在线,无法授权续跑' }
@@ -1082,7 +921,7 @@ export function createDispatch(ctx, state, deps) {
1082
921
  }
1083
922
  rec.settled = true // 先立旗:堵迟到的 idle/超时臂重入(与事件臂同一纪律)
1084
923
  console.error('[task-board] 续跑会话已不存在(sendMessage 后活跃目录未命中,死会话哨兵),立即失败重派 task ' + rec.taskId + ' child ' + rec.childId)
1085
- settleRun(sid, rec, null, new Error('续跑会话已不存在(sendMessage 后未在活跃目录命中,死会话哨兵)'))
924
+ settle.settleRun(sid, rec, null, new Error('续跑会话已不存在(sendMessage 后未在活跃目录命中,死会话哨兵)'))
1086
925
  }
1087
926
  var q
1088
927
  try { q = subagents.listChildren(sid) } catch (e) { return }
@@ -1094,895 +933,38 @@ export function createDispatch(ctx, state, deps) {
1094
933
  rec.deathDispose = function () { try { if (typeof h === 'function') h() } catch (_) {} }
1095
934
  }
1096
935
 
1097
- // run 结算:保证收尾;工具通道(board_report/board_verdict)已推进状态的话文本路径跳过
1098
- // ===== 结算收尾三件套(task-muwkhqf8)=====
1099
- // 无论从哪条入口进来(事件通道 settleContinuable / 上报通道 board_report),run 记录的收尾动作
1100
- // 必须一致:①关 run 结局(closeRunHistory)②usage 落账(seq 水位线增量)③摘除软/硬超时臂。
1101
- // 三条各自幂等:重复结算(idle 事件重入 / 上报与事件双通道先后到达)不二次关账、不二次记账。
1102
- // ===== 结算≠停止根修(task-mv8wuznb,ontology 实证 2026-10-15:卡 02:02 已 resolved、verdict 已落账,
1103
- // verifier 子会话却持续写盘空转到 10:16 被主窗口手杀——8 小时空烧)=====
1104
- // 病根:一次性 run 的 settle 只关账(dispose 只释放本地 run 句柄,不停子会话的 agent loop)——
1105
- // verifier 调完 board_verdict 后 turn 未结束(继续探索/循环),会话持续空转烧 token。
1106
- // 一次性子代理「结算即销毁」是设计语义,这里补齐销毁的半步:结算落定后对该 run 的子会话补一枪
1107
- // interrupt(与 endContinuable 同款 authority 形状,fire-and-return 同步发信号即返回)。
1108
- // 挂载点取 settleRunRecord 末尾 = 所有一次性落定路径的唯一汇合点(settleRun 的
1109
- // completed/failed/incomplete、幽灵回收 reapGhostRecs 的收尾都经这里)——含事故原形
1110
- // 「board_verdict 落账 → 卡 resolved → rec 下轮被幽灵回收」那条(rec 在表外时 settleRun 根本跑不到)。
1111
- // 边界(零改动):hook 点位不在本卡范围;重复 interrupt 无害(取消信号幂等)。continuable 豁免经
1112
- // task-mvbeallq 收紧:原「continuable 即跳」过宽(无续跑位的残影借豁免续跑),现仅「续跑位真在
1113
- // 本 child 手上」才放行,其余 continuable 落定同样补停——收紧细则与竞态论证见函数头注释。
1114
- // 失败只 console.error:结算已落定,残留空转只影响 token 不影响正确性(与 terminate 的 killFail
1115
- // 告警分级不同——那是用户显式要杀,这里是收尾补强)。
1116
- async function stopSettledOneShot(sid, rec) {
1117
- if (!rec || !rec.id) return
1118
- if (rec.role !== 'verifier' && rec.role !== 'worker') return // 只对 verifier 与 worker 动手
1119
- var subagents = ctx.subagents
1120
- if (!subagents || typeof subagents.interrupt !== 'function') return // 老宿主/老测试桩无此能力:逐字不变
1121
- var parent = rootForSession(sid)
1122
- if (!parent) return // 无活 root 可授权(root 已死/重建窗口)→ 跳过,结算语义不受影响
1123
- // ===== continuable 豁免收紧(task-mvbeallq,残影治理①)=====
1124
- // 原闸门「rec.continuable 即跳」过宽:continuable 但**已无续跑位**的落定——completed(resumeTarget
1125
- // 只认超时/失败结局)/ noResume 落旗(reap 失败)/ 卡面最新 worker 留档已被更新的 run 取代
1126
- // (池故障后主窗口重派了新 Worker)——借豁免续跑就是残影(ontology 实证:旧子会话残影续跑 5 分钟
1127
- // 逐文件推进,与新 Worker 差点同批文件互踩)。interrupt 对 continuable 只打断 turn、不销毁会话
1128
- // (endContinuable 同款语义),补停安全。豁免只剩「续跑位真在本 child 手上」两种形态:
1129
- // ① 本卡活跃表已有接管**同一子会话**的新 rec——续跑在途,此刻打断会杀掉刚复活的新 turn。
1130
- // (续跑路径纪律:sendMessage 返回后**零 await**先登记 rec 再追加续跑留档,故新 turn 全程
1131
- // 被本形态覆盖;不同 id 的新 rec=fresh spawn 新 Worker,不豁免——旧残影照停。)
1132
- // ② 锁外新鲜快照上 resumeTarget 仍指向本 child——续跑位未被消费,下轮重派会冷复活它。
1133
- // (豁免判定必须读新鲜快照:结算刚关账,只有自己内存里的 rec 不足以判定留档次序——
1134
- // 幽灵回收路径上卡面可能已有更新的 worker 留档。)
1135
- // 两形态之外一律补停;快照读不到(t=null)按「无续跑位」处理——宁停勿留残影。
1136
- if (rec.continuable) {
1137
- var cur = runsFor(sid)[rec.taskId]
1138
- if (cur && String(cur.id) === String(rec.id)) return // ① 续跑在途(新 rec 接管同一子会话)
1139
- var t = null
1140
- try {
1141
- var dsnap = await rt(sid)
1142
- 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 } } }
1143
- } catch (_) { t = null }
1144
- if (t && resumeTarget(t) === String(rec.id)) return // ② 续跑位在手:留存会话供冷复活
1145
- }
1146
- try {
1147
- subagents.interrupt(String(rec.id), { kind: 'ancestor', agent: parent })
1148
- } catch (e) {
1149
- console.error('[task-board] 一次性 run 结算后 interrupt 子会话失败(会话可能仍在空转,只影响 token)task ' + rec.taskId + ' child ' + rec.id + ':', String(e))
1150
- }
1151
- }
1152
-
1153
- async function settleRunRecord(sid, rec, opts) {
1154
- // ① 结局落卡:failed → 有 err 记 timeout/error,无 err 记 incomplete(与一次性路径口径一致);
1155
- // 成功 → completed。noResume 旗随 rec.resumeBlocked(interrupt 失败)落卡。
1156
- try {
1157
- await closeRunHistory(sid, rec.taskId, String(rec.id), opts.outcome, rec.continuable ? { noResume: rec.resumeBlocked === true } : null)
1158
- } catch (e) { console.error('[task-board] closeRunHistory 收尾失败 (task ' + rec.taskId + '):', String(e)) }
1159
- // 统一健康表被动面:每个可归因 run 落定都记最近 5 次成败与本次延迟;父模型 id 不可知则跳过。
1160
- if (rec.model) observeModel(sid, rec.model, opts.outcome === 'completed', { latencyMs: Math.max(0, Date.now() - Number(rec.startedAt || Date.now())) })
1161
- // 熔断连击归零(task-mvowb2hc 洞①):连击只在「连续」语义内计数——任一可归因 run 正常完成
1162
- // 即证明模型链路恢复,清零重新计数(失败落定不动连击,也归不了零)。
1163
- if (rec.model && opts.outcome === 'completed') resetModelFailure(sid, rec.model)
1164
- // ② usage 落账:读该次 run 的 v4 日志聚合(水位线增量),累加到任务。放在状态推进之后:
1165
- // 统计是附加信息,读日志失败/无 usage 时静默跳过,绝不影响结算语义。
1166
- // Worker 失败重试、驳回重做都会各走一次 settleRun,因此多轮消耗天然累加(runs 计数)。
1167
- try { await accumulateRunUsage(sid, rec) } catch (e) { console.error('[task-board] usage 收尾失败 (task ' + rec.taskId + '):', String(e)) }
1168
- // ③ 摘除两级超时臂:软臂不再刷假告警,硬臂不再对已结算的闲置会话补一枪 interrupt。
1169
- try { if (typeof rec.disarm === 'function') rec.disarm() } catch (_) {}
1170
- // ② settle 通道心跳(运行时健康自检):三件套各自幂等且各自 catch 永不抛,
1171
- // 走到这里即「一次成功结算活动」——rpc 侧据此判定「结算通道 >30min 无活动」黄条。
1172
- poolHealthFor(sid).settleLastOkAt = Date.now()
1173
- // ④ 结算≠停止根修(task-mv8wuznb):落定后主动 interrupt 子会话,堵「结算≠停止」空转洞。
1174
- // task-mvbeallq 起改 async(continuable 豁免收紧需读锁外快照算 resumeTarget)——interrupt 本身
1175
- // 仍是同步信号 fire-and-return;闸门与 try/catch 全在函数内/调用处,绝不阻断/改变结算语义;
1176
- // 续跑位在手的 continuable 与 hook 在函数内被原样放行(豁免边界见函数头注释)。
1177
- try { await stopSettledOneShot(sid, rec) } catch (e) { console.error('[task-board] 结算后补停子会话异常(不影响结算)task ' + rec.taskId + ':', String(e)) }
1178
- }
1179
-
1180
- // ===== 拆解官结算(task-mvnenrwk):host 读 result.structured 物化 draft 子卡 =====
1181
- // 三分支:verdict=split → applyDecomposeProposal 物化(幂等清旧批 draft 提案)+ 源卡摘要消息 + 通知;
1182
- // verdict=keep → 源卡记「无需拆分:rationale」;
1183
- // run 失败 / structured 缺失 / 提案畸形 → 源卡记失败原因 + 通知(源卡与旧提案批都不动)。
1184
- // 通知走 pushSysNote 且**不带 taskId**:源卡停在 draft/pending,带 taskId 的系统通知会在回执冲刷时
1185
- // 被「任务仍在执行中」过滤器整行丢弃(notify.mjs deliver 段)——提案到位是必须送达的信号。
1186
- async function settleDecompose(sid, rec, res, err) {
1187
- var failed = !!err || (res && res.stopReason && res.stopReason !== 'completed')
1188
- var errText = err ? String(err) : (res && (res.diagnostic || res.stopReason) || '')
1189
- var structured = (res && res.structured && typeof res.structured === 'object') ? res.structured : null
1190
- var applied = null
1191
- try {
1192
- applied = await mutateLocked(sid, function (d) {
1193
- var t = d.tasks.find(function (x) { return x.id === rec.taskId })
1194
- if (!t) return null
1195
- if (!Array.isArray(t.messages)) t.messages = []
1196
- if (failed) {
1197
- t.messages.push({ kind: 'decompose', text: '🧩 拆解官 run 失败(' + String(errText || '未知错误').slice(0, 200) + ')——源卡未改动,可重试 task_decompose', at: new Date().toISOString(), by: 'system' })
1198
- return { ok: false, failed: true }
1199
- }
1200
- if (!structured) {
1201
- t.messages.push({ kind: 'decompose', text: '🧩 拆解官未返回结构化提案(structured 缺失,可能宿主/provider 不支持 outputSchema)——源卡未改动,可重试 task_decompose', at: new Date().toISOString(), by: 'system' })
1202
- return { ok: false, failed: true }
1203
- }
1204
- return applyDecomposeProposal(d, t, structured, new Date().toISOString())
1205
- }, true) // skipKick:物化的是 draft 子卡(不参与派发),无需踢 poolCycle
1206
- } catch (e) { console.error('[task-board] settle decompose failed (task ' + rec.taskId + '):', String(e)); applied = null }
1207
- try {
1208
- if (applied && applied.ok && applied.verdict === 'split') pushSysNote(sid, '🧩 拆解提案已就位(' + applied.children.length + ' 张草稿子卡,源卡 ' + rec.taskId + '),确认后逐张 publish')
1209
- else if (applied && applied.ok && applied.verdict === 'keep') pushSysNote(sid, '🧩 拆解官判定源卡(' + rec.taskId + ')无需拆分,理由已落源卡消息')
1210
- else pushSysNote(sid, '🚨 拆解官 run 未产出可用提案(源卡 ' + rec.taskId + '):' + String((applied && applied.error) || errText || 'structured 缺失').slice(0, 200))
1211
- } catch (e) { console.error('[task-board] decompose notify failed:', String(e)) }
1212
- }
1213
-
1214
- async function settleRun(sid, rec, res, err) {
1215
- if (runsFor(sid)[rec.taskId] !== rec) return // 已被 terminate 等路径处理
1216
- delete runsFor(sid)[rec.taskId]
1217
- // 收尾分路(卡2):一次性 run 走 dispose(既有语义逐字不变);continuable 走 interrupt——
1218
- // **只打断当前 turn,不销毁子会话**(Activation/未认领收件箱/已发布后代全部保留),
1219
- // idle 后仍可被 sendMessage 冷复活,这正是重派续跑的前提。
1220
- try {
1221
- if (rec.continuable) await endContinuable(sid, rec)
1222
- else await rec.run.dispose()
1223
- } catch (_) {}
1224
- var output = outputText(res)
1225
- var failed = !!err || (res && res.stopReason && res.stopReason !== 'completed')
1226
- var errText = err ? String(err) : (res && (res.diagnostic || res.stopReason) || '')
1227
- try {
1228
- if (rec.role === 'worker') await settleWorker(sid, rec, output, failed, errText)
1229
- else if (rec.role === 'hook-pre' || rec.role === 'hook-post') await settleHook(sid, rec, output, failed, errText)
1230
- else if (rec.role === 'decomposer') await settleDecompose(sid, rec, res, err)
1231
- else await settleVerifier(sid, rec, output, failed, errText)
1232
- } catch (e) { console.error('[task-board] settle ' + rec.role + ' failed (task ' + rec.taskId + '):', String(e)) }
1233
- // 历史会话留档 + usage + 超时臂收尾:三件套抽到 settleRunRecord(上报通道共用同一实现)。
1234
- // 结局同时是**续跑资格**的唯一依据(continuable + timeout/error|incomplete → 下次重派改续跑),
1235
- // noResume 旗(interrupt 失败)一并落卡,让重派直接回退 fresh spawn。
1236
- // ⚠️ 位置在状态推进**之后**:rec.resumeBlocked 由 endContinuable 在推进前写好,这里读到的才是终值。
1237
- await settleRunRecord(sid, rec, { outcome: failed ? (err ? 'timeout/error' : 'incomplete') : 'completed' })
1238
- }
1239
-
1240
- // ===== 可续跑 Worker 的 turn 结算(task-muw5gnhv 卡1;卡2 加续跑基线)=====
1241
- // 与一次性路径的关系:任务推进语义**完全复用** settleWorker(board_report 工具优先、文本兜底同构),
1242
- // 差别只在「怎么知道 turn 完了」与「怎么收尾」:
1243
- // · 一次性:run.result 落定 → settleRun(res,err)(有结构化结果/失败原因);
1244
- // · continuable:没有 run.result,只能靠 host 事件 agent/status 的 running→idle 观测 turn 结束
1245
- // (见 onAgentStatus)。因此这里没有 res:输出文本从子会话 v4 日志尾部的 assistant/message 兜底读;
1246
- // 日志读不到且文本为空时按失败结算(走 pending 重试),**绝不当成「空完成」推进到 verifying**——
1247
- // 否则一次「没跑起来就 idle」的事件会把任务误判为有交付物。
1248
- // 收尾三件套(关 run 结局 / usage 落账 / 摘超时臂)不在本函数里单独写一遍,而是随 settleRun →
1249
- // settleRunRecord 一起做——它与上报通道(board_report → settleReportedRun)共用同一实现
1250
- // (task-muwkhqf8):两条入口只是「谁来触发」,收尾口径必须只有一套。
1251
- async function settleContinuable(sid, rec) {
1252
- if (runsFor(sid)[rec.taskId] !== rec) return // 已被 terminate/硬超时等路径认领
1253
- var text = ''
1254
- // 续跑轮(rec.baselineBytes>0)只读基线之后的字节:本轮没写东西就是空文本 → 按失败重排,
1255
- // 不会把上一轮(被中断那次)残留的助手文本冒充成本轮交付物(同一条失败臂,语义不变)。
1256
- try { text = childSessionOutput(rec.childId, rec.baselineBytes) } catch (e) { console.error('[task-board] continuable 输出读取失败 (' + rec.childId + '):', String(e)) }
1257
- if (!text.trim()) console.error('[task-board] continuable child ' + rec.childId + ' idle 但未读到 assistant 文本,按失败结算(任务 ' + rec.taskId + ')')
1258
- await settleRun(sid, rec, { output: [{ type: 'text', text: text }], stopReason: text.trim() ? 'completed' : 'error' }, null)
1259
- }
1260
-
1261
- // continuable 子会话的收尾(卡2:**interrupt 留存,不销毁**)——
1262
- // 失败/硬超时结算时只发一个取消信号打断当前 turn:Activation、未认领的收件箱、已发布后代全部保留,
1263
- // 子会话 idle 后仍能被 sendMessage 冷复活(Step2 续跑的前提);一次性路径的 dispose 不动。
1264
- // authority 形状取 dsh-subagent 的 SubagentInterruptAuthority:{kind:'ancestor', agent: 活的直接父 Agent};
1265
- // 该调用同步 fire-and-return(发完取消信号即返回,目标可能到下一个可观察点才真正停下)。
1266
- // interrupt 失败(会话已死 / 权限不符 / 宿主无此能力)→ **绝不阻断结算**:任务照常回 pending 重排;
1267
- // 只给 rec 立 resumeBlocked 旗 → 该 run 落 noResume 标记,重派时直接 fresh spawn(不空唤醒、不卡死)。
1268
- async function endContinuable(sid, rec) {
1269
- if (!rec || !rec.childId) return rec
1270
- var subagents = ctx.subagents
1271
- if (!subagents || typeof subagents.interrupt !== 'function') return rec
1272
- var parent = rootForSession(sid)
1273
- if (!parent) { rec.resumeBlocked = true; return rec }
1274
- try {
1275
- subagents.interrupt(rec.childId, { kind: 'ancestor', agent: parent })
1276
- } catch (e) {
1277
- rec.resumeBlocked = true
1278
- console.error('[task-board] interrupt 失败,该子会话不再续跑(重派回退 fresh spawn)child ' + rec.childId + ':', String(e))
1279
- }
1280
- return rec
1281
- }
1282
-
1283
- // 读子会话 v4 日志尾部的助手文本(continuable 路径的文本兜底通道):
1284
- // 与 rpc.mjs agent-activity 同一套 helper/事件形状(assistant/message.content[].text),
1285
- // 只是这里要的是**最后一条完整助手文本**(供 settleWorker 解析分段格式 / [ESCALATE])。
1286
- // 只读尾部 2MB:回复在末尾,整份读大日志会拖慢结算路径;读不到一律返回 ''(调用方按失败处理)。
1287
- // minBytes > 0(续跑轮):只读文件该偏移之后的字节——帧从新到旧,切在半帧上的那一帧由
1288
- // readLogFrames 逐帧 catch 降级丢弃;本轮没产出任何助手文本就返回 ''(走「空文本按失败」)。
1289
- function childSessionOutput(childId, minBytes) {
1290
- if (!childId) return ''
1291
- // 与 logSizeOf/readRunUsage 同源:显式带上 sessionsRoot(生产注入 undefined → 回退 os.homedir(),
1292
- // 与本次改造前逐字一致;单测注入临时目录时才读得到真日志——此前漏传会让「文本兜底」这一路永远读空)。
1293
- var log = findRunLog(childId, sessionsRoot)
1294
- if (!log) return ''
1295
- var tail = 2 * 1024 * 1024
1296
- if (typeof minBytes === 'number' && minBytes > 0) {
1297
- var sz = 0
1298
- try { sz = statSync(log).size || 0 } catch (_) { sz = 0 }
1299
- if (sz <= minBytes) return '' // 续跑基线之后一个字节都没写 → 本轮没有交付物
1300
- tail = sz - minBytes
1301
- }
1302
- var buf = readLogBytes(log, tail)
1303
- if (!buf) return ''
1304
- var frames = readLogFrames(buf, 0)
1305
- var out = ''
1306
- for (var fi = 0; fi < frames.length; fi++) {
1307
- var evs = frames[fi]
1308
- for (var i = 0; i < evs.length; i++) {
1309
- var e = evs[i]; var dta = (e && e.data) || {}
1310
- if (!e || e.type !== 'assistant/message') continue
1311
- var msg = dta.message || {}
1312
- var blocks = Array.isArray(msg.content) ? msg.content : []
1313
- var parts = []
1314
- for (var b = 0; b < blocks.length; b++) { if (blocks[b] && blocks[b].type === 'text' && blocks[b].text) parts.push(String(blocks[b].text)) }
1315
- if (parts.length) out = parts.join('\n') // 帧从新到旧:最后命中覆盖前值 → 得到最新一条助手文本
1316
- }
1317
- }
1318
- return out
1319
- }
1320
-
1321
- // 本地日期 key(YYYY-MM-DD):byDay 日账的唯一口径。
1322
- // 必须用本地 getters 拼——toISOString() 是 UTC,晚上 8 点后的消耗会被记到次日,
1323
- // 「今日消耗」在东八区会从每天 08:00 起算,直接错位。
1324
- function localDayKey(d) {
1325
- var x = d || new Date()
1326
- function p2(n) { return (n < 10 ? '0' : '') + n }
1327
- return x.getFullYear() + '-' + p2(x.getMonth() + 1) + '-' + p2(x.getDate())
1328
- }
1329
-
1330
- // 有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)。
1331
- // 为什么单列:本板实测累计 total 里缓存读占 94%(9.7M/10.3M),大数字被缓存读撑高约 17 倍,
1332
- // 与「真实花掉多少」严重脱节——有效消耗才是可比的成本口径,缓存读单列展示。
1333
- function effectiveOf(u) {
1334
- if (!u) return 0
1335
- return (u.input || 0) + (u.output || 0) + (u.cacheWrite || 0)
1336
- }
1337
-
1338
- // ===== usage 增量水位线(卡3 Step1):这一轮该从哪个 seq 之后继续累加 =====
1339
- // 读同一 rec.id 的历史 runs 条目上落的水位线(runs[i].usageSeq = 上次结算时该会话日志的 maxSeq)。
1340
- // 为什么锚在 runs 条目而不是内存表:重启后内存表清零而卡上留档还在,续跑轮才能接着上次水位;
1341
- // 且同一 childId 的多次派发各占一条 runs(resume 轮追加新条目),水位随条目天然分段、互不串账。
1342
- // ⚠️ 倒序找到「同 id 的条目」还不能停:**续跑轮的新条目与旧条目同 id**(同一会话),新条目此时
1343
- // 还没结算过、usageSeq 未设——必须继续往前找第一条**已结算过**(usageSeq > 0)的条目,否则水位线
1344
- // 恒为 0 → 每轮都把整份日志重算一遍(本实现踩过的坑,实测总量双倍 25 而非 15)。
1345
- // 一条都没有(首次结算 / 手写裁剪过的老任务)→ 0 = 全量累加(与本次改造前逐字一致)。
1346
- function seekSeqOf(snap, taskId, runId) {
1347
- try {
1348
- var tasks = (snap && snap.tasks) || []
1349
- for (var ti = 0; ti < tasks.length; ti++) {
1350
- var t = tasks[ti]
1351
- if (!t || t.id !== taskId || !Array.isArray(t.runs)) continue
1352
- for (var ri = t.runs.length - 1; ri >= 0; ri--) {
1353
- var r = t.runs[ri]
1354
- if (!r || String(r.id) !== String(runId)) continue
1355
- var w = num0(r.usageSeq)
1356
- if (w > 0) return w
1357
- }
1358
- }
1359
- } catch (_) {}
1360
- return 0
1361
- }
1362
- // 水位线字段兜底成非负数字(脏值/老形态一律当 0 = 全量,宁可多记也不误丢)
1363
- function num0(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
1364
-
1365
- // 该 run 记录是否已落过 usage(usageRecorded 旗):倒序找同 id 的条目,见旗即真。
1366
- // 判据为什么要**同一 childId 复用**(而不是只认最新条目):continuable 的续跑轮与首轮共用同一个
1367
- // 子会话 id,closeRunHistory 会把每一轮的条目都更新成终局——「最新那条」在续跑轮结算时正是本轮
1368
- // 新建的那条(usageRecorded 未置,照常记账)。真重复结算(事件通道与上报通道都来/上报后 idle 补到)
1369
- // 时最新条目就是那份已记账的旧条目 → 见旗即跳过。两处语义互斥,同一判据够用,不需要另立新字段。
1370
- function hasUsageRecorded(snap, taskId, runId) {
1371
- try {
1372
- var tasks = (snap && snap.tasks) || []
1373
- for (var ti = 0; ti < tasks.length; ti++) {
1374
- var t = tasks[ti]
1375
- if (!t || t.id !== taskId || !Array.isArray(t.runs)) continue
1376
- for (var ri = t.runs.length - 1; ri >= 0; ri--) {
1377
- var r = t.runs[ri]
1378
- if (!r || String(r.id) !== String(runId)) continue
1379
- return r.usageRecorded === true // 同 id 的最新条目就是本轮该结算的那条
1380
- }
1381
- }
1382
- } catch (_) {}
1383
- return false
1384
- }
936
+ // 拆解官结算(settleDecompose)已拆至 lib/settle.mjs(拆分第 6 刀 task-mvt8c1yx-6):
937
+ // settleRun 分路模块内直调,本文件不再持有角色结算函数(settleWorker/settleVerifier/settleHook 同批搬出)。
1385
938
 
1386
- // 把一次 run 的 token 消耗累加到任务(t.usage):总量/输入/输出/缓存读写 + 按模型小计 + runs 计数 + 日账。
1387
- // 模型小计的 key:优先本次派发显式覆盖的模型(rec.model),否则用日志里记录的会话模型。
1388
- // 日账(byDay):本次 run 整笔记到「结算时刻的本地日」——一次 run 不跨日拆分
1389
- // (跨零点的长 run 全算在结算日),换取实现极简与仪表盘「今日 / 近 7 天」可算。
1390
- // 双指标形态:byDay[day] = { t: total, e: effective }(e 是有效消耗,不含缓存读)。
1391
- // 老数据(number 形态,本轮之前落的日账)只在聚合端兼容:读侧按 { t: n, e: null } 处理,
1392
- // e 不可知就置 null(宁可展示上标 ~ 近似,也不伪造一个「有效值」)。
1393
- // 增量口径(卡3 Step1):结算前先读一次看板取该 run 的历史水位线(seekSeqOf),只把这之后的
1394
- // assistant/message 增量计入;累加时把本份日志的 maxSeq 一并落到该 runs 条目上(下一轮从这继续)。
1395
- // one-shot 路径(每 run 独立日志、永远找得到自己的条目且首次无水位)→ 行为逐字不变:全量累加一次。
1396
- async function accumulateRunUsage(sid, rec) {
1397
- var u = null
1398
- var since = 0
1399
- try {
1400
- var snap0 = await rt(sid)
1401
- since = seekSeqOf(snap0, rec.taskId, rec.id)
1402
- // 幂等(task-muwkhqf8):该条 run 记录已落过账 → 直接跳过。为什么需要它:continuable 的 run 现在有
1403
- // 两条结算入口(事件通道 + board_report 上报通道),且两条可能在「先读到快照、后持锁写」的窗口里
1404
- // 交错——只靠水位线挡不住同一次结算被记两笔(两次都读到同一旧快照)。旗与累加在同一持有锁回调里
1405
- // 写,读序天然串行。判据只看**同 id 的最新条目**(见 hasUsageRecorded:续跑轮新条目未置旗,照常记账)。
1406
- if (hasUsageRecorded(snap0, rec.taskId, rec.id)) return
1407
- u = readRunUsage(String(rec.id), sessionsRoot, since)
1408
- } catch (_) { u = null }
1409
- if (!u || !u.total) return
1410
- var eff = effectiveOf(u)
1411
- try {
1412
- await mutateLocked(sid, function (d) {
1413
- var t = d.tasks.find(function (x) { return x.id === rec.taskId })
1414
- if (!t) return null
1415
- if (!t.usage) t.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, runs: 0, models: {} }
1416
- // 逐字段兜底 0:脏数据(半写/老形态)也不会把 NaN 带进看板(NaN 会让工具输出的 lossless-JSON 校验拒整条结果)
1417
- t.usage.input = (t.usage.input || 0) + u.input
1418
- t.usage.output = (t.usage.output || 0) + u.output
1419
- t.usage.cacheRead = (t.usage.cacheRead || 0) + u.cacheRead
1420
- t.usage.cacheWrite = (t.usage.cacheWrite || 0) + u.cacheWrite
1421
- t.usage.total = (t.usage.total || 0) + u.total
1422
- t.usage.runs = (t.usage.runs || 0) + 1
1423
- if (!t.usage.models) t.usage.models = {}
1424
- var mk = rec.model || u.model || '(未知模型)'
1425
- t.usage.models[mk] = (t.usage.models[mk] || 0) + u.total
1426
- // 日账:老任务没有 byDay 就地补(不改写老字段形态);键是本地日 YYYY-MM-DD。
1427
- if (!t.usage.byDay) t.usage.byDay = {}
1428
- // 脏值兜底:历史/半写数据里 byDay[dk] 可能是 number(老形态)或字符串,一律收敛成对象记账
1429
- var dk = localDayKey()
1430
- var cell = t.usage.byDay[dk]
1431
- if (!cell || typeof cell !== 'object') cell = { t: Number(cell) || 0, e: 0 }
1432
- cell.t = (Number(cell.t) || 0) + u.total
1433
- cell.e = (Number(cell.e) || 0) + eff
1434
- t.usage.byDay[dk] = cell
1435
- // ===== run 级留账:把本次 run 的用量原样写回它在 t.runs 里的条目 =====
1436
- // 作用:总量口径万一再要调整(换分母/排除某类 run/按天或按模型重建),不必回头猜——
1437
- // 每个 run 自带五分量,任意维度都能精确重算(byDay 只是它的一个投影)。
1438
- // 找不到条目就跳过:t.runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,
1439
- // 正常结算路径必存在;只有手写/裁剪过的历史任务会缺,缺了也不该阻断聚合累加。
1440
- // 置位 usageRecorded = 该条 run 已落账(幂等旗,见函数头注释与 hasUsageRecorded):
1441
- // 与累加同一次持有锁写,读侧据此挡住「同一结算被两条入口各记一笔」。
1442
- if (Array.isArray(t.runs)) {
1443
- for (var ri = t.runs.length - 1; ri >= 0; ri--) {
1444
- if (String(t.runs[ri] && t.runs[ri].id) === String(rec.id)) {
1445
- t.runs[ri].usage = { input: u.input || 0, output: u.output || 0, cacheRead: u.cacheRead || 0, cacheWrite: u.cacheWrite || 0, total: u.total || 0 }
1446
- // 水位线落卡(卡3 Step1):本份日志已结算到 maxSeq;续跑轮再结算时只认这之后的帧。
1447
- // 只增不减(max 收敛):异常情况下读到较小值也不让水位倒退(倒退=把已结算帧再记一遍)。
1448
- t.runs[ri].usageSeq = Math.max(num0(t.runs[ri].usageSeq), num0(u.maxSeq))
1449
- t.runs[ri].usageRecorded = true
1450
- // 模型补落账(task-muxhshfu 记分卡卡1①):派发未显式覆盖模型时条目 model='',
1451
- // 而日志 request/context 里记着真实模型(u.model,与任务级 u.models 小计同源)——
1452
- // 结算时顺手回填,记分卡卡2 的「模型×场景」聚合就不必再走按任务级占比摊派的近似路径。
1453
- // 只填空不覆盖:派发显式覆盖的模型名优先(aggregateUsageSummary 模型 key 同一优先序)。
1454
- if (!t.runs[ri].model && u.model) t.runs[ri].model = String(u.model)
1455
- break
1456
- }
1457
- }
1458
- }
1459
- t.usage.updatedAt = new Date().toISOString()
1460
- return { ok: true }
1461
- })
1462
- } catch (e) { console.error('[task-board] usage accumulate failed (task ' + rec.taskId + '):', String(e)) }
1463
- }
1464
-
1465
- // ===== 上报通道结算入口(task-muwkhqf8 回归修复的第二条入口)=====
1466
- // 病根:continuable Worker 的 run 记录此前**只有事件通道**会收尾(agent/status 的 running→idle →
1467
- // settleContinuable → settleRun)。但 Worker 通过 board_report 工具上报完成时,任务状态已被工具通道
1468
- // 推进到 verifying/resolved——此时若该 turn 的 idle 事件没被观测到(rec.ran 二次守卫挡下的伪 idle、
1469
- // 驱动未接上的冷窗口、宿主重启、idle 事件丢失),run 记录就永久停在 outcome='running':
1470
- // ① 软超时臂对它误报(实证:已完成的 run 收到「worker 已运行 30 分钟仍未完成」);
1471
- // ② usage 不落账(仪表盘漏计这轮消耗);
1472
- // ③ 硬超时臂到期还对一条早已结算的闲置会话补一枪 interrupt(噪音 + 误导)。
1473
- // 解法:board_report/board_verdict 把任务推进到落定态(verifying/resolved)时,顺带调本函数——
1474
- // 对**该任务当前的 continuable Worker rec**补做收尾三件套(关 run 结局 / usage 落账 / 摘超时臂),
1475
- // 与事件通道共用 settleRunRecord(同一实现,杜绝两条路各写一套口径)。
1476
- // 三点边界:
1477
- // · 只认 continuable 且 role==='worker' 的活跃 rec:verifier/hook 是一次性 run。它们的 run.result
1478
- // 落定后 settleRun 会照常收尾;手动终止路径**刻意**不关 run(保留 running = 不留续跑资格),此处不碰。
1479
- // · 摘除活跃表项:任务已落定,这张卡不该再占 Worker 并发位;否则 poolCycle 的 activeW 会把它算成
1480
- // 在跑的 Worker(幽灵占位)。子会话由 settleRunRecord 末尾的 stopSettledOneShot 补停
1481
- // (task-mvbeallq:completed 落定天然无续跑位——resumeTarget 只认超时/失败结局——「续跑豁免」
1482
- // 留下的就是残影续跑窗;补停只打断 turn 不销毁会话,与 endContinuable 同款语义)。
1483
- // · 幂等:closeRunHistory 只关 outcome==='running' 的条目、accumulateRunUsage 认 usageRecorded 旗、
1484
- // rec.disarm 幂等——重复上报 / 上报后 idle 事件补到,都不会二次关账或二次记账。
1485
- // 老宿主/没接线的调用方:deps 里没有这个函数时 rpc 侧静默跳过,行为与本次改造前逐字一致。
1486
- async function settleReportedRun(sid, taskId, opts) {
1487
- var rec = runsFor(sid)[taskId]
1488
- if (!rec || !rec.continuable || rec.role !== 'worker') return false
1489
- if (runsFor(sid)[rec.taskId] !== rec) return false
1490
- delete runsFor(sid)[rec.taskId] // 与 settleRun 同口径:先摘活跃表项,堵住并发重入
1491
- rec.settled = true
1492
- await settleRunRecord(sid, rec, { outcome: (opts && opts.outcome) || 'completed' })
1493
- return true
1494
- }
939
+ // usage 水位与落账(localDayKey/effectiveOf/seekSeqOf/num0/hasUsageRecorded/accumulateRunUsage)已拆至
940
+ // lib/settle.mjs(拆分第 7 刀 task-mvt8c1yx-7):settleRunRecord 改模块内直调,return API 的
941
+ // accumulateRunUsage 懒绑定重指 settle 实例(index.mjs 消费面零变化)。
1495
942
 
1496
- // ===== host 事件订阅:agent/status(continuable Worker 的 turn 结算触发器,task-muw5gnhv 卡1)=====
1497
- // 事件契约(host Event 目录实证):'agent/status'(payload: { agent, status: 'idle'|'running' })。
1498
- // 「子代理 turn 完成」在事件面就是 running→idle;continuable 没有 run.result promise,故这是唯一可靠的
1499
- // 结算信号。订阅形态:ctx.on(本插件 host 侧首个事件订阅点),一律走 ctx.effect 回收——
1500
- // 插件卸载/HMR 重载时监听器必须随之注销,否则重载后同一事件会被多份旧闭包重复结算(重复结算虽被
1501
- // rec.settled / runsFor 认领双重挡住,但旧闭包里过期的 state 引用本身就是内存泄漏)。
1502
- // 二次守卫 rec.ran:只有**观测到过 running** 的 rec 才允许被 idle 结算。理由:startContinuable 返回后
1503
- // 子会话可能先报一个瞬时 idle(驱动尚未接上/冷复活窗口),若拿它结算,任务会以「空文本」被推进/回退。
1504
- // 真实 turn 必然先 running 后 idle,因此该守卫只挡伪 idle,不挡正常结算。
1505
- function onAgentStatus(payload) {
1506
- try {
1507
- // ⚠️ 不能在此只放行 idle:running 事件正是 rec.ran 的唯一来源(见下方守卫),
1508
- // 早退会让 rec.ran 永远 false → idle 永远不结算(任务只能等硬超时)——本实现踩过的坑。
1509
- var status = payload && payload.status
1510
- if (status !== 'idle' && status !== 'running') return
1511
- var agent = payload.agent
1512
- // 身份来源修正(2026-10-06 热修,活体实证):dsh-agent 的 Agent 接口**没有 id 字段**——
1513
- // 被驱动的会话才是身份(agent.session.id)。此前读 agent.id 恒 undefined → cid='' 早退,
1514
- // 事件结算通道从卡1上线起从未真正触发(所有结算全靠 run.result / board_report 通道顶着)。
1515
- // agent.id 作兜底保留(防未来内核补上该字段时形状分叉)。
1516
- var cid = agent ? String((agent.session && agent.session.id) || agent.id || '') : ''
1517
- if (!cid) return
1518
- var table = state.activeRuns
1519
- // 事件按会话 id 命中活跃 rec:continuable rec 的 childId 就是子会话 id(= agent.session.id),
1520
- // 故这里是 O(1) 的 rec.childId 比对,不做任何 agent 注册表扫描。
1521
- Object.keys(table).forEach(function (sid) {
1522
- var rr = table[sid]; if (!rr) return
1523
- Object.keys(rr).forEach(function (k) {
1524
- var rec = rr[k]
1525
- if (!rec || !rec.continuable || rec.childId !== cid) return
1526
- if (status === 'running') { rec.ran = true; return }
1527
- if (!rec.ran || rec.settled) return
1528
- rec.settled = true // 先立旗:堵住硬超时臂与重复事件的并发重入
1529
- settleContinuable(sid, rec).catch(function (e) { console.error('[task-board] settleContinuable failed (task ' + rec.taskId + '):', String(e)) })
1530
- })
1531
- })
1532
- } catch (e) { console.error('[task-board] agent/status 处理失败:', String(e)) }
1533
- }
943
+ // 帧活性追踪(frameSessionIdOf/frameStreamSeen/onAssistantFrame)已拆至 lib/hung.mjs(拆分第 1 刀 task-mvt8c1yx),
944
+ // 事件订阅改挂 hung.onAssistantFrame 实例方法(下方)。
1534
945
  // 注册(cordis 标准事件订阅):返回的 disposer 交给 ctx.effect 回收。
1535
946
  // 老宿主/测试桩上没有 ctx.on 时静默跳过——派发照常,只是没有事件结算通道(一次性路径本就不需要)。
1536
947
  if (typeof ctx.on === 'function') {
1537
948
  try {
1538
- ctx.effect(function () { return ctx.on('agent/status', onAgentStatus) })
949
+ ctx.effect(function () { return ctx.on('agent/status', settle.onAgentStatus) })
1539
950
  } catch (e) { console.error('[task-board] agent/status 订阅失败:', String(e)) }
1540
- }
1541
-
1542
- async function settleWorker(sid, rec, output, failed, errText) {
1543
- // 冷却嗅探②(continuable 死子路径):失败结算文本命中限流/连接故障 → 本 run 所用模型登记冷却表。
1544
- // activation.result 拆除兜底臂带上来的诊断文本(含「限额将在 … 重置」/ ENOTFOUND 等)也走这里。
1545
- // rec.model 空 = 父模型(id 不可知)无法归因,跳过;badModels 熔断语义不受影响(两张表各管各的)。
1546
- if (failed && rec.model) await sniffModelCooldown(sid, rec.taskId, rec.model, errText)
1547
- // 完成回执开关(设置区「通知」notifyDone):在同一次持锁回调里读看板文档(零额外读盘),
1548
- // 缺字段/回调未跑到时保持 true——宁可多报一条,也不因读配置失败漏报完成。
1549
- var doneOn = true
1550
- var result = await mutateLocked(sid, function (d) {
1551
- doneOn = cfg(d).notifyDone !== false
1552
- var t = d.tasks.find(function (x) { return x.id === rec.taskId })
1553
- if (!t) return null
1554
- // 工具通道已处理(board_report 已推进到 verifying/resolved 或挂了 escalation)→ 只收尾
1555
- if (t.status !== 'in-progress' || t.escalation) return { task: t, already: true }
1556
- if (failed) {
1557
- // 失败原因落卡(截断 300):卡片详情页可直接查看最近失败原因,排查三连败不再靠猜
1558
- t.lastError = String(errText || '').slice(0, 300)
1559
- t.retryCount = (t.retryCount || 0) + 1
1560
- // ===== 宿主秒拆喂熔断(task-mvowb2hc 洞①):spawn/续跑后即被宿主拆除(stopReason:error)
1561
- // = 模型面失败,计入熔断连击——原形 task-mvoutja4:glm-5.3-flash 秒拆 ×3 不计入 badModels,
1562
- // 熔断永不触发,档位链每轮重派都再选同一个坏模型。连续 3 次才拉黑(熔断语义不变:badModels
1563
- // 落黑名单 → 取模链跳过 + 健康表 circuit);worker 自己的失误不计,成功 run 归零(settleRunRecord)。
1564
- if (rec.model && isModelKillFailure(errText)) feedModelFailure(sid, rec.model)
1565
- 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 } }
1566
- 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)')
1567
- return { task: t, retry: true }
1568
- }
1569
- if (/\[ESCALATE\]/i.test(output || '')) {
1570
- t.escalation = { question: output.slice(0, 2000), at: new Date().toISOString(), by: String(rec.id) }
1571
- if (!Array.isArray(t.messages)) t.messages = []
1572
- t.messages.push({ kind: 'escalation', text: output.slice(0, 4000), at: t.escalation.at, by: String(rec.id) })
1573
- ah(t, 'in-progress', 'in-progress', String(rec.id), 'worker 上报歧义(文本通道),待主窗口裁决')
1574
- return { task: t, escalated: true }
1575
- }
1576
- // 文本降级路径:分段格式上报
1577
- var secs = parseSections(output)
1578
- delete t.retryCount; delete t.stuckSince; delete t.lastError // 成功路径:失败计数/卡死标记/最近失败原因一并清除
1579
- 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) }
1580
- resolveApply(d, t, String(rec.id), 'verifying', output || 'Worker 完成', 'worker 文本上报完成')
1581
- return { task: t }
1582
- })
1583
- if (!result) return
1584
- // already=true:工具通道(board_report)已推进状态并已发回执/歧义通知,settle 只负责 dispose,不再重复通知
1585
- if (result.already) return
1586
- if (result.escalated) maybeNotify(sid, result.task)
1587
- // 歧义通知(maybeNotify → notifyMainWindow)是裁决通道,不受回执开关影响:只闸下面两条完成/阻塞回执
1588
- if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
1589
- if (doneOn && result.blocked) notifyTaskDone(sid, result.task, 'blocked')
1590
- kickCycle(sid) // 结算后立刻补派
1591
- }
1592
-
1593
- async function settleVerifier(sid, rec, output, failed, errText) {
1594
- // 完成回执开关同上:持锁回调内取 notifyDone
1595
- var doneOn = true
1596
- var result = await mutateLocked(sid, function (d) {
1597
- doneOn = cfg(d).notifyDone !== false
1598
- var t = d.tasks.find(function (x) { return x.id === rec.taskId })
1599
- if (!t) return null
1600
- if (t.escalation) return { task: t, already: true }
1601
- if (t.status !== 'verifying') {
1602
- // 迟到落账(验收时序三洞 ③):verifier 结论落到已落定卡(主窗口抢批/归档早于本 run 结算)不再静默丢弃。
1603
- // 但「工具通道已处理」= board_verdict 已由本 run 自己落账(t.verification.by === rec.id)→ 保持 already。
1604
- if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
1605
- if (t.verification && String(t.verification.by) === String(rec.id)) return { task: t, already: true }
1606
- var ltrim = (output || '').trim()
1607
- var lvm = ltrim.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
1608
- if (failed || !lvm) return { task: t, already: true } // 无有效结论,不落 late
1609
- var lapproved = lvm[1].toUpperCase() === 'APPROVED'
1610
- var lsecs = parseSections(ltrim)
1611
- 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)
1612
- if (lr && lr.invalid) return { task: t, already: true } // 三连根修①:非法 verdict 零落账(防御——本路径 verdict 恒来自正则解析)
1613
- return { task: t, late: true, lateRejected: lr.lateRejected && lr.wasApproved }
1614
- }
1615
- return { task: t, already: true }
1616
- }
1617
- var trimmed = (output || '').trim()
1618
- var vm = trimmed.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
1619
- if (failed || !vm) {
1620
- // 失败原因落卡(截断 300):verifier 故障/输出无法判定的原因留在卡片上供排查
1621
- t.lastError = String(errText || 'verifier 未给出有效结论(输出格式异常)').slice(0, 300)
1622
- // 失败/空输出/无法判定:verifyRetries 计数,>=3 转人工验收(deliverable 已完成,是 verifier 故障不是任务故障)
1623
- if (failed && rec.model) {
1624
- badModels[modelKey(sid, rec.model)] = true
1625
- // 熔断文案分态(task-mv73ym2k,实证 n-mv6ezj6vary4):错误串含 UNKNOWN_MODEL = 模型 id 非法
1626
- // (mistake 类:配置写错,模型无辜)——与「连续验收失败」(quality 类)区分,不归一堆。
1627
- var modelIllegal = /UNKNOWN_MODEL/i.test(String(errText || ''))
1628
- markModelCircuit(sid, rec.model, modelIllegal ? '模型 id 配置非法,已熔断' : '验收连续失败,已熔断')
1629
- pushSysNote(sid, modelIllegal
1630
- ? '模型 ' + rec.model + ' 配置非法(不在可用清单),已熔断回退父级模型——请经 list-models 选合法 id 重配'
1631
- : '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型')
1632
- }
1633
- t.verifyRetries = (t.verifyRetries || 0) + 1
1634
- 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 } }
1635
- ah(t, 'verifying', 'verifying', 'system', 'verifier 未给出有效结论,重新排队审查 (' + t.verifyRetries + '/3)')
1636
- return { task: t, retry: true }
1637
- }
1638
- var approved = vm[1].toUpperCase() === 'APPROVED'
1639
- var vsecs = parseSections(trimmed)
1640
- delete t.stuckSince; delete t.verifyRetries; delete t.lastError // 成功给出结论:卡死标记/重试计数/最近失败原因一并清除
1641
- t.verification = { verdict: approved ? 'approved' : 'rejected', summary: vsecs.verifySummary || trimmed.slice(0, 600), checks: vsecs.checks || '', at: new Date().toISOString(), by: String(rec.id) }
1642
- // 自测指南落账(verifyUserGuide 开关门禁,task-muxyyvg0):开关开且解析出「## 自测指南」段才挂
1643
- // userTest;缺段/开关关 → 字段不挂(client 详情块与报告「本版自测清单」段整块不渲染)
1644
- if (cfg(d).verifyUserGuide !== false && vsecs.userTest) t.verification.userTest = vsecs.userTest
1645
- verifyApply(d, t, String(rec.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
1646
- if (!approved) {
1647
- t.rejectCount = (t.rejectCount || 0) + 1
1648
- // 驳回包全量带回(task-muvg15p5):summary + checks(逐条核对证据)落 messages,
1649
- // 随 buildMessages 注入重派 Worker prompt——history 里只有 200 字截断,Worker 据此无法返工
1650
- pushRejection(t, vsecs.verifySummary, vsecs.checks, t.verification.at, String(rec.id))
1651
- // 学习飞轮 v1:Verifier 驳回 → 候选教训(场景/错误做法/来源),随 t.verification.at 判重
1652
- pushRejectLesson(d, t, vsecs.verifySummary || trimmed, t.verification.at)
1653
- if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') }
1654
- else { t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, 'in-progress', 'pending', 'system', '驳回重派:新 Worker 将携带驳回原因继续') }
1655
- }
1656
- return { task: t, approved: approved }
1657
- })
1658
- if (!result) return
1659
- // already=true:工具通道(board_verdict)已推进状态并已发回执,settle 只负责 dispose,不再重复通知
1660
- if (result.already) return
1661
- // 迟到通知分态(三连根修②③,task-mvf7nve8):rejected 且卡已 approved → err 级「🚨 迟到驳回」+
1662
- // healthHint(banner 只 rejected 亮);approved → notice 级「✅ 迟到复核:维持原判」。
1663
- // 同指纹(taskId+by+verdict,poolHealth.lastLateNotice)已投过 → 跳过,零重复通知。
1664
- if (result.late) {
1665
- var __phL = poolHealthFor(sid)
1666
- var __fpL = lateNoticeFp(result.task)
1667
- var __onceL = __phL.lastLateNotice !== __fpL
1668
- if (__onceL) __phL.lastLateNotice = __fpL
1669
- if (result.lateRejected) {
1670
- __phL.lateRejectNote = { taskId: result.task.id, at: Date.now() } // banner 幂等 sticky,不受去重闸影响
1671
- if (__onceL && notifyLateReject) notifyLateReject(sid, result.task)
1672
- } else if (__onceL && notifyLateApprove && result.task.lateVerdict && result.task.lateVerdict.verdict === 'approved') {
1673
- notifyLateApprove(sid, result.task)
1674
- }
1675
- return
1676
- }
1677
- if (result.escalated) maybeNotify(sid, result.task)
1678
- if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
1679
- if (doneOn && result.task && result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
1680
- kickCycle(sid)
1681
- }
1682
-
1683
- // ===== hook run 结算(hooks=agent run 接线②)=====
1684
- // 照 worker/verifier 的结算模式:mutateLocked 持锁改状态机(状态机纯函数在 core.applyHookSettle,
1685
- // 单测直接打它),锁外发通知 + kickCycle 补派。
1686
- // hook-pre 完成 → hooks.pre.state='done'(下轮起子任务正常放行,串行闸门打开)
1687
- // hook-post 完成 → hooks.post.state='done' + epic 转 verifying(收口完成,交人验收)
1688
- // 失败(异常/stopReason 异常)或文本 [ESCALATE] → 该点位 state='failed' + epic 转 blocked +
1689
- // escalation 挂卡(歧义上报「前置准备失败,重试/跳过/放弃」),不自动重试。
1690
- // 幂等:epic 已有未裁决 escalation 时只收口不覆盖(主窗口裁决前不刷屏)。
1691
- async function settleHook(sid, rec, output, failed, errText) {
1692
- var phase = rec.role === 'hook-pre' ? 'pre' : 'post'
1693
- var esc = /\[ESCALATE\]/i.test(output || '')
1694
- var runId = String(rec.id)
1695
- // 完成回执开关(notifyDone):hook 收口把 epic 推进到 blocked/verifying 同样算完成回执,一视同仁
1696
- var doneOn = true
1697
- var result = await mutateLocked(sid, function (d) {
1698
- doneOn = cfg(d).notifyDone !== false
1699
- var p = d.tasks.find(function (x) { return x.id === rec.taskId })
1700
- if (!p) return null
1701
- return applyHookSettle(d, rec.taskId, phase, !(failed || esc), output, runId, errText)
1702
- })
1703
- if (!result) return
1704
- if (result.already) return
1705
- if (result.blocked) { maybeNotify(sid, result.task); if (doneOn) notifyTaskDone(sid, result.task, 'blocked') }
1706
- if (doneOn && result.closed) notifyTaskDone(sid, result.task, 'resolved')
1707
- kickCycle(sid) // 结算后立刻补派(pre 完成 → 子任务开跑;post 完成 → epic 进验收)
1708
- }
1709
-
1710
- // ===== 重启 reconcile(卡3 Step2):host 重启后把活跃 continuable Worker 的观测重新挂回来 =====
1711
- // 问题:continuable Worker 的 rec 只活在内存(state.activeRuns)——host 重启把它清零,而子会话
1712
- // 本身是**持久子会话**,可能正跑得好好(或刚跑完一轮)。此时任务停在 in-progress 却没人认领:
1713
- // 既不会被 agent/status 结算(表里没 rec),又不会被孤儿回收(isOrphan 的 2 分钟门槛+它确实
1714
- // 有 claimedBy),只能干等——重启一次就白挂一个 Worker 位。
1715
- // 解法(一次,per host 生命周期):对本会话板上「in-progress 且无活跃 rec」的任务查 listChildren(root),
1716
- // · 该 childId 仍在列 → 判定存活:重建 rec 观测(agent/status 监听本来就在,登记即可续上),
1717
- // 基线取**当前日志字节数**(重启前那轮的产出已落盘,不该冒充重启后的新交付物),重挂两级超时臂;
1718
- // · 不在列 → 会话已死:走「硬超时等价物」——失败结算的等价动作(回 pending 重排 + 留历史),
1719
- // 不占用 Worker 并发位,等下一轮 poolCycle 正常重派(续跑资格由卡上 runs 留档决定)。
1720
- // 三道门禁:①只对 role=worker + continuable + 结局停在 running 的 run(一次性 run 没有可找回的会话);
1721
- // ②per host 生命周期只做一次(state.reconcileDone)——重复做会把用户手动终止的卡又挂回去;
1722
- // ③listChildren 不可用(老宿主/测试桩)→ 整段跳过,行为逐字不变。
1723
- // 已知边界(有意不做,交给既有机制):重启**之后**才被中断的那一轮,reconcile 追不回它的结局——
1724
- // 硬超时臂已在重建时重挂,到点照常结算。
1725
- async function reconcileRoot(sid) {
1726
- if (!state.reconcileDone) state.reconcileDone = {} // 老宿主/测试桩没建这个容器:就地补(不清空已有标记)
1727
- if (state.reconcileDone[sid]) return
1728
- state.reconcileDone[sid] = true
1729
- var subagents = ctx.subagents
1730
- if (!subagents || typeof subagents.listChildren !== 'function') return
1731
- var runs = runsFor(sid)
1732
- var snap = null
1733
- try { snap = await rt(sid) } catch (_) { return }
1734
- // 候选:in-progress + 在跑占位已换成真实 run id + 无活跃 rec + 最后一条 worker run 是 continuable
1735
- // 且结局仍停在 running(已落 timeout/error 的卡由重派路径自己处理,不在此处抢跑)。
1736
- var cands = []
1737
- var list = (snap && snap.tasks) || []
1738
- for (var i = 0; i < list.length; i++) {
1739
- var t = list[i]
1740
- if (!t || t.status !== 'in-progress' || t.escalation) continue
1741
- if (runs[t.id]) continue
1742
- if (!t.claimedBy || t.claimedBy === 'spawn-pending') continue
1743
- if (resumeTarget(t) !== '') continue // 结局已落超时/失败 → 归重派续跑路径,reconcile 不掺和
1744
- var last = null
1745
- var trs = Array.isArray(t.runs) ? t.runs : []
1746
- for (var ri = trs.length - 1; ri >= 0; ri--) { if (trs[ri] && trs[ri].role === 'worker') { last = trs[ri]; break } }
1747
- if (!last || last.continuable !== true || last.outcome !== 'running') continue
1748
- if (String(last.id) !== String(t.claimedBy)) continue // 卡上留档与占位不一致:状态可疑,不动
1749
- cands.push(t)
1750
- }
1751
- // ===== 孤儿一次性子会话补停(task-mv8wuznb ②,重启场景)=====
1752
- // 卡已落定(resolved/cancelled/archived)而 verifier/一次性 worker 的 run 留档仍 running 无
1753
- // endedAt:重启把内存 rec 清零后,结算收尾(stopSettledOneShot)的 interrupt 再也打不到它——
1754
- // 若子会话仍活在宿主目录(下方 listChildren 命中),顺手 interrupt 停掉(「结算即销毁」的重启补课,
1755
- // 实证:verifier 空转 8h 的会话若中途遇 host 重启,苏醒后照旧空烧)。
1756
- // 只停会话、不动卡面:running 留档的关账归 sweepZombieRuns(每轮都跑,就排在本函数之后)。
1757
- // continuable worker 豁免(留存会话供续跑是设计语义;在飞卡的 continuable 由上方候选①重建观测)。
1758
- var orphans = []
1759
- for (var oi = 0; oi < list.length; oi++) {
1760
- var ot = list[oi]
1761
- if (!ot || (ot.status !== 'resolved' && ot.status !== 'cancelled' && ot.status !== 'archived')) continue
1762
- var ors = Array.isArray(ot.runs) ? ot.runs : []
1763
- for (var oj = 0; oj < ors.length; oj++) {
1764
- var orun = ors[oj]
1765
- if (!orun || orun.outcome !== 'running' || orun.endedAt) continue
1766
- if (orun.role !== 'verifier' && !(orun.role === 'worker' && orun.continuable !== true)) continue
1767
- orphans.push(String(orun.id))
1768
- }
1769
- }
1770
- if (!cands.length && !orphans.length) return
1771
- // 存活判定:listChildren 列出的就是「活的子会话」。带超时保护(查询挂死也不能拖住心跳)。
1772
- var kids = null
1773
951
  try {
1774
- var q = subagents.listChildren(sid)
1775
- kids = (typeof withTimeout === 'function') ? await withTimeout(q, 5000, 'reconcile listChildren') : await q
1776
- } catch (e) {
1777
- console.error('[task-board] reconcile listChildren 失败(本轮跳过,卡片留在 in-progress 等人工处理):', String(e))
1778
- return
1779
- }
1780
- var alive = {}
1781
- var kidList = Array.isArray(kids) ? kids : []
1782
- for (var k = 0; k < kidList.length; k++) {
1783
- var c = kidList[k]; if (!c) continue
1784
- var cid = String(c.sessionId || c.id || '')
1785
- if (cid) alive[cid] = true
1786
- }
1787
- var c2 = cfg(snap)
1788
- var found = 0, lost = 0
1789
- for (var ci = 0; ci < cands.length; ci++) {
1790
- var tc = cands[ci]
1791
- var childId = String(tc.claimedBy)
1792
- if (alive[childId]) {
1793
- // ① 存活:重建 rec(与 spawnOneShot 的 continuable 形态逐字同构)→ 重挂两级超时臂。
1794
- // ran:false —— 重启前的 running 事件已经错过,等下一个真 running 事件到来才允许 idle 结算
1795
- // (rec.ran 二次守卫的既有语义:宁可等,也不拿一个可能已在跑的会话的空文本当交付物)。
1796
- 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) }
1797
- runs[tc.id] = rec
1798
- if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
1799
- dispatchedEver[sid][childId] = true
1800
- armTimeouts(sid, rec, tc, c2)
1801
- found++
1802
- // 系统消息只留一行(重启是低频事件,且这行是「为什么它还在跑」的唯一解释来源)
1803
- pushSysNote(sid, '任务「' + tc.title + '」的 Worker 子会话已在重启后找回(续跑观测已恢复,硬超时 ' + c2.hardTimeoutMin + ' 分钟后照常结算)', tc.id)
1804
- } else lost++
1805
- }
1806
- if (found || lost) console.log('[task-board] 重启 reconcile(' + sid + '):找回活跃续跑 Worker ' + found + ' 个,' + lost + ' 个子会话已死转回 pending')
1807
- // ② 会话已死:硬超时等价物(失败结算的等价动作)——回 pending 重排 + 留一行 history + 落结局。
1808
- // 放在**锁外**统一做(一次持锁批量处理,避免逐张写盘打断派发周期)。
1809
- if (lost) {
1810
- try {
1811
- await mutateLocked(sid, function (d) {
1812
- d.tasks.forEach(function (t) {
1813
- if (t.status !== 'in-progress' || runs[t.id] || !t.claimedBy || t.claimedBy === 'spawn-pending') return
1814
- var last = null
1815
- var trs = Array.isArray(t.runs) ? t.runs : []
1816
- for (var ri = trs.length - 1; ri >= 0; ri--) { if (trs[ri] && trs[ri].role === 'worker') { last = trs[ri]; break } }
1817
- if (!last || last.continuable !== true || last.outcome !== 'running') return
1818
- if (String(last.id) !== String(t.claimedBy) || alive[String(t.claimedBy)]) return
1819
- var ps = t.status
1820
- t.status = 'pending'; t.claimedBy = null; t.claimedAt = null
1821
- t.lastError = 'host 重启后 Worker 子会话已不存在(reconcile 未找回),任务回收重排'
1822
- last.outcome = 'timeout/error'; last.endedAt = new Date().toISOString()
1823
- ah(t, ps, 'pending', 'system', '重启后执行会话已丢失,回收重新排队')
1824
- return
1825
- })
1826
- return { ok: true }
1827
- }, true)
1828
- } catch (e) { console.error('[task-board] reconcile 死会话回收失败:', String(e)) }
1829
- }
1830
- // 孤儿一次性子会话补停(task-mv8wuznb ②):只打「仍在活跃目录」的——不在目录 = 会话已死,
1831
- // 杀无可杀(留档归 sweepZombieRuns 关账);无 interrupt 能力/无活 root → 静默跳过;
1832
- // 单条失败只 console.error,不拖住本轮 reconcile 的其它动作。
1833
- var stopped = 0
1834
- if (orphans.length && typeof subagents.interrupt === 'function') {
1835
- var oroot = rootForSession(sid)
1836
- if (oroot) {
1837
- for (var oi2 = 0; oi2 < orphans.length; oi2++) {
1838
- var oid = orphans[oi2]
1839
- if (!alive[oid]) continue
1840
- try { subagents.interrupt(oid, { kind: 'ancestor', agent: oroot }); stopped++ }
1841
- catch (e) { console.error('[task-board] reconcile 孤儿一次性子会话 interrupt 失败(' + oid + '):', String(e)) }
1842
- }
1843
- }
1844
- }
1845
- if (stopped) console.log('[task-board] 重启 reconcile(' + sid + '):补停落定卡孤儿一次性子会话 ' + stopped + ' 个')
952
+ ctx.effect(function () { return ctx.on('agent/assistant-stream', hung.onAssistantFrame) })
953
+ } catch (e) { console.error('[task-board] agent/assistant-stream 订阅失败:', String(e)) }
1846
954
  }
1847
955
 
1848
- // ===== 僵尸 run 留档清扫(task-mv22z61v):关账「落定卡上的 running 无 endedAt 留档」+ 清卡面认领位 =====
1849
- // 事故(用户实证):0.4.1/0.4.5 时代老卡,宿主多次重启后 run 留档停在 outcome:'running' 无 endedAt +
1850
- // 卡面 verifierRun/claimedBy 残留指向死会话。归档门禁 activeRunOf 的卡面兜底(liveEntry 认 claimedBy/
1851
- // verifierRun 匹配的 running 留档)把它们误判活跃 → archive 永久拒。mv1pqy27 的 doTerminate 状态无关
1852
- // 清理只覆盖「rec 在内存表」场景;重启后 rec 消失 + 卡已落定的组合落入盲区(resolved 无分支,什么都不清),
1853
- // 且没有任何工具能关一条无 rec 的 run 留档本体。
1854
- // 修法:扫描本板卡片的 runs[],凡 outcome==='running' 且无 endedAt 且该 run id 不在内存活跃表(runsFor)
1855
- // 且所属卡已落定(resolved/cancelled/archived)→ 关账(endedAt=now,outcome 改 'stale-closed',注记
1856
- // note='host 重启僵尸留档清扫')+ 清对应卡面认领位(verifierRun/claimedBy 若指向它)。
1857
- // 边界(宁少勿多):
1858
- // ① 只对落定卡做——在飞卡的 running 留档是 reconcile / 结算通道的辖区,别越界;
1859
- // ② 无活跃 rec 才扫——内存表里仍活着(!settled)的 run 留给结算通道 / reapGhostRecs 关账(它们用
1860
- // 'completed'/'timeout-error' 等真结局,而非这里的 'stale-closed',口径更准);
1861
- // ③ 幂等——关账后 outcome!=running,二次运行零改动;回调无改动时返回 null 不写盘(沿用空闲快进的
1862
- // 「不写盘」纪律,安静板不因清扫产生 15s 心跳写盘);
1863
- // ④ 留痕——history 行 + console 一行(有产出时)。
1864
- // 'stale-closed' 是既有枚举之外的新结局值:详情页/聚合端对未知 outcome 一律按「已结束(有 endedAt)」
1865
- // 兜底展示,不会把它误判成 running(running 的判据是 outcome==='running' 且无 endedAt,二者已同时改写)。
1866
- async function sweepZombieRuns(sid, snap) {
1867
- if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0 // 瞬时读盘失败/空板:跳过,不写盘
1868
- // 预扫快照:有候选(落定卡 + running 无 endedAt 留档)才进锁写;否则零 mutateLocked 调用——
1869
- // 空板/安静板走空闲快进「不写盘」的纪律不受清扫破坏(每 15s 心跳也不能因清扫多出一次写盘调用)。
1870
- var hasCandidate = false
1871
- for (var pi = 0; pi < snap.tasks.length && !hasCandidate; pi++) {
1872
- var pt = snap.tasks[pi]
1873
- if (!pt || (pt.status !== 'resolved' && pt.status !== 'cancelled' && pt.status !== 'archived')) continue
1874
- var prs = Array.isArray(pt.runs) ? pt.runs : []
1875
- for (var pj = 0; pj < prs.length; pj++) { if (prs[pj] && prs[pj].outcome === 'running' && !prs[pj].endedAt) { hasCandidate = true; break } }
1876
- }
1877
- if (!hasCandidate) return 0
1878
- var runs = runsFor(sid)
1879
- var swept = []
1880
- await mutateLocked(sid, function (d) {
1881
- if (!d || !Array.isArray(d.tasks)) return null
1882
- var any = false
1883
- d.tasks.forEach(function (t) {
1884
- if (!t) return
1885
- if (t.status !== 'resolved' && t.status !== 'cancelled' && t.status !== 'archived') return // 只清扫已落定卡
1886
- if (!Array.isArray(t.runs)) return
1887
- var cardChanged = false
1888
- t.runs.forEach(function (r) {
1889
- if (!r || r.outcome !== 'running' || r.endedAt) return // 只关「还开着且无 endedAt」的条目
1890
- var rid = String(r.id)
1891
- var rec = runs[t.id]
1892
- if (rec && !rec.settled && String(rec.id) === rid) return // 内存活跃表里还活着 → 结算通道辖区,不扫
1893
- r.outcome = 'stale-closed'
1894
- r.endedAt = new Date().toISOString()
1895
- r.note = 'host 重启僵尸留档清扫'
1896
- if (t.verifierRun === rid) { t.verifierRun = null; delete t.verifierRunAt }
1897
- if (t.claimedBy === rid) { t.claimedBy = null; t.claimedAt = null }
1898
- swept.push(t.id + '/' + rid + '/' + (r.role || '?'))
1899
- cardChanged = true
1900
- })
1901
- if (cardChanged) { ah(t, t.status, t.status, 'system', '僵尸 run 留档已关账(host 重启清扫)'); any = true }
1902
- })
1903
- return any ? d : null // 无改动返回 null → 不写盘(安静板不因清扫产生心跳写盘)
1904
- }, true) // skipKick:本轮已是 poolCycle,无需再踢
1905
- if (swept.length) console.log('[task-board] 僵尸 run 留档清扫 ' + swept.length + ' 条:' + swept.join(', '))
1906
- return swept.length
1907
- }
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 补派随源码搬移。
1908
963
 
1909
- // ===== 幽灵占位回收(池冻结根修,task-muwlepg6)=====
1910
- // 病根(活体实证):活跃表项(rec)的**唯一**摘除点是各结算通道(事件通道 settleRun /
1911
- // 上报通道 settleReportedRun / 手动终止)。任何一条漏走(worker 经 board_report 完成但当时
1912
- // 还没有上报通道接线、伪 idle 被 rec.ran 守卫挡下、冷窗口、插件热重载丢回调)都会让 rec
1913
- // 永久留在 state.activeRuns 里,而它同时是四个判定的输入:
1914
- // ① activeW → capW = max(0, maxWorkers - activeW):残留数 ≥ maxWorkers 时 capW 恒 0 →
1915
- // **整池 Worker 自动派发全停**(实测板:5 个残留 + 1 个真在跑 → activeW=6 > maxWorkers=3 → capW=0,
1916
- // 9 张 pending 卡一张都派不出去);
1917
- // ② pickDispatch 的 busyTaskIds(= 本表)→ 该卡自己永远进不了 Verifier 派发(verifying 卡永不 spawn);
1918
- // ③ isOrphan 的 !runs[t.id] → 该卡永不被孤儿回收(in-progress 死卡持着 touches 锁堵住后续卡);
1919
- // ④ poolStatus 快照 → UI 上「幽灵 Worker 卡 busy」。
1920
- // 四者叠加 = 「poolCycle 整体冻结,force dispatch-task 手动通道却正常」——手动通道
1921
- // (rpc dispatch-task → spawnOneShot)不经 capW / busyTaskIds 闸门,故症状完全吻合。
1922
- // 修法:不动任何结算语义,只做**表项 GC**——每轮 cycle 在算活跃度之前,用「卡面证据」核对每个 rec:
1923
- // 卡已不存在、或卡的状态已不可能是该 rec 的在跑态(worker 不在 in-progress / verifier 不在 verifying /
1924
- // hook 状态不是 running)、或卡的认领位已换成别的 run id → 该 rec 已失效,立刻摘除。
1925
- // 摘除是**同步**的(先删表项,本轮 activeW/busyTaskIds 立刻干净、派发当轮恢复),随后用与两条
1926
- // 结算通道**同一个** settleRunRecord 补一次收尾(结局 / usage / 摘超时臂,三者各自幂等)——
1927
- // 不另立第二套口径,也不重复记账(usageRecorded 旗与「只关还开着的条目」双保险)。
1928
- // 边界(宁少勿多,绝不动真在跑的表项):
1929
- // · 卡面读不到(__noPersist 空板 / tasks 非数组)→ 整段跳过:瞬时读盘失败不能把真 Worker 当幽灵杀;
1930
- // · 只认卡面证据,**不认** rec.settled——settled=true 的表项可能正被结算通道认领中
1931
- // (onAgentStatus 先立旗、同一次同步块里才摘表项),此刻抢先摘除会让 settleRun 的认领守卫
1932
- // 早退、把这一轮的交付物整份丢掉;
1933
- // · 单条判定/收尾各自 try/catch:一条脏数据只影响该条,不拖死本轮其它回收与派发。
1934
- function recIsLive(t, rec) {
1935
- if (!t || !rec) return false // 卡已不存在(板被重建/换工作区)→ 表项无主
1936
- if (rec.role === 'worker') {
1937
- // Worker 只在 in-progress 活着;claimedBy 为空 = 占位没落座/已被清(僵尸态)
1938
- if (t.status !== 'in-progress' || !t.claimedBy) return false
1939
- if (t.claimedBy !== 'spawn-pending' && String(t.claimedBy) !== String(rec.id)) return false // 认领位已换人(terminate 后重派)
1940
- return true
1941
- }
1942
- if (rec.role === 'verifier') {
1943
- if (t.status !== 'verifying') return false
1944
- if (t.verifierRun && t.verifierRun !== 'spawn-pending' && String(t.verifierRun) !== String(rec.id)) return false
1945
- return true
1946
- }
1947
- if (rec.role === 'hook-pre') return t.status === 'in-progress' && hookOn(t, 'pre') && t.hooks.pre.state === 'running'
1948
- if (rec.role === 'hook-post') return t.status === 'in-progress' && hookOn(t, 'post') && t.hooks.post.state === 'running'
1949
- // 拆解官(task-mvnenrwk):挂在 draft/pending 源卡上且全程不动卡面状态——源卡仍在草稿/待办即真在跑;
1950
- // 卡被删除/翻走(如人工强制派发进 in-progress)→ 表项失效,按幽灵回收(迟到结算被 settled 旗挡下)。
1951
- if (rec.role === 'decomposer') return t.status === 'draft' || t.status === 'pending'
1952
- // 未知角色(前向兼容):只要卡还在跑态就留着,等卡落定后由这里回收——不在未知形态上做激进判定
1953
- return t.status === 'in-progress' || t.status === 'verifying'
1954
- }
1955
- // 返回本轮摘除条数(调用方据此决定要不要重读盘——收尾写盘会让本轮快照过期)
1956
- async function reapGhostRecs(sid, snap) {
1957
- if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0
1958
- var runs = runsFor(sid)
1959
- var tasks = snap.tasks
1960
- var reaped = []
1961
- Object.keys(runs).forEach(function (k) {
1962
- var rec = runs[k]
1963
- if (!rec) return
1964
- try {
1965
- var t = null
1966
- for (var i = 0; i < tasks.length; i++) { if (tasks[i] && tasks[i].id === rec.taskId) { t = tasks[i]; break } }
1967
- if (recIsLive(t, rec)) return
1968
- delete runs[k] // 先摘表项:本轮 activeW / busyTaskIds / poolStatus 立刻不再被它污染
1969
- try { if (typeof rec.disarm === 'function') rec.disarm() } catch (_) {} // 摘两级超时臂(幂等)
1970
- rec.settled = true // 堵住迟到的结算通道(settleRun 的认领守卫会早退,不会二次推进任务)
1971
- // 结局口径:卡已落定(resolved/archived/cancelled)或已交验收(verifying)→ 这轮 Worker 交付
1972
- // 确实完成了;卡还在跑态但认领位换了人/卡丢了 → 结局不可知,记 incomplete(与一次性路径的
1973
- // 「无 err 的失败」同口径,且据此保留续跑资格由重派路径判断)。
1974
- var closed = t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled' || t.status === 'verifying'
1975
- reaped.push({ rec: rec, outcome: closed ? 'completed' : 'incomplete' })
1976
- } catch (e) { console.error('[task-board] 幽灵表项判定失败(本轮跳过该条):', String(e)) }
1977
- })
1978
- if (!reaped.length) return 0
1979
- for (var r = 0; r < reaped.length; r++) {
1980
- try { await settleRunRecord(sid, reaped[r].rec, { outcome: reaped[r].outcome }) }
1981
- catch (e) { console.error('[task-board] 幽灵表项收尾失败 (task ' + reaped[r].rec.taskId + '):', String(e)) }
1982
- }
1983
- console.log('[task-board] 回收失效活跃表项(幽灵占位)' + reaped.length + ' 个:' + reaped.map(function (x) { return String(x.rec.taskId) + '/' + String(x.rec.role) + '/' + x.outcome }).join(', '))
1984
- return reaped.length
1985
- }
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 调用。
1986
968
 
1987
969
  // ===== 派发周期(15s 心跳 + 写入后 kickCycle 触发)=====
1988
970
  // 结构(池冻结防御,task-muwlepg6):入口 root 闸门 → 读盘 → 本轮主体(poolCycleBody)整段包 try。
@@ -2021,22 +1003,29 @@ export function createDispatch(ctx, state, deps) {
2021
1003
  // 重启 reconcile(卡3 Step2):只在本 host 生命周期的首轮 cycle 做一次(内部有 reconcileDone 标记)。
2022
1004
  // 位置取舍:root 闸门必须是第一件事(无活 root 的板零 IO),所以本段只能排在早退闸门与 rt 读盘之后;
2023
1005
  // 而重建出来的 rec 仍在本轮被读到(活跃计数 / 池状态快照都在下面才求值),语义不受影响。
2024
- 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)) }
2025
1007
  // 僵尸 run 留档清扫(task-mv22z61v):位置在 reconcile 之后、幽灵回收与空闲快进之前。
2026
1008
  // 落定卡的僵尸留档无活跃 rec,reconcile(只在飞卡)/reapGhostRecs(只清内存表项)都管不到它,
2027
1009
  // 而它若留在 running 会卡死 archive;安静板(只有已落定卡)会走空闲快进早退,清扫必须排在其前才能兜到。
2028
- 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)) }
2029
1011
  // 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
2030
1012
  // 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
2031
1013
  // 故有回收时重读一次快照——否则下面空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(写盘已归一进锁)。
2032
1014
  var reapedN = 0
2033
- 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)) }
2034
1016
  if (reapedN > 0) {
2035
1017
  try { snap = await rt(sid) } catch (_) {}
2036
1018
  // ③ 幽灵回收可视化(运行时健康自检):本轮回收 >0 留一条一次性记录(90s 保鲜),
2037
1019
  // rpc get-tasks 读到即在「架构健康」区亮一条 info 并消费——幽灵回收从此在 GUI 可见。
2038
1020
  poolHealthFor(sid).reapNote = { n: reapedN, at: Date.now() }
2039
1021
  }
1022
+ // 挂死零产出检测(task-mvqcoz9l):位置在幽灵回收之后、活跃度计数与派发决策之前——
1023
+ // 判死结算把 rec 摘出活跃表(activeW 当轮即干净、空出的并发位本轮即可被 pickDispatch 复用),
1024
+ // 卡面由失败臂翻回 pending,同一轮即可重派;判死结算会写盘(关账/落卡),有判死时重读快照——
1025
+ // 否则下方空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(与幽灵回收同款纪律)。
1026
+ var hungN = 0
1027
+ try { hungN = await hung.hungZeroOutputSweep(sid, runs, snap) } catch (e) { console.error('[task-board] 挂死零产出检测失败(本轮跳过):', String(e)) }
1028
+ if (hungN > 0) { try { snap = await rt(sid) } catch (_) {} }
2040
1029
  var activeW = 0, activeV = 0
2041
1030
  // 角色口径:worker 计入 activeW(占 Worker 并发位);verifier 与 hook run(hook-pre/hook-post)
2042
1031
  // 统一计入 activeV——hook run 不是 Worker,不该挤占 maxWorkers 并发位,但它确实是一条在跑的 run,
@@ -2317,8 +1306,10 @@ export function createDispatch(ctx, state, deps) {
2317
1306
  // state.activeRuns = {} 等价)。原位清空是必要的——runsFor(sid) 返回的是每会话子对象,
2318
1307
  // 只换顶层容器会把这些子对象连同里面的 rec 一起漏掉。
2319
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 = {} } })
2320
- // 卸载同时停主动嗅探: AbortSignal 取消在飞请求,迟到结果不再写健康表;已有被动表随 host 状态一并废弃。
2321
- ctx.effect(function () { return function () { probeDisposed = true; Object.keys(probeAbort).forEach(function (model) { try { probeAbort[model].abort() } catch (_) {} }); probeNextAt = {} } })
1309
+ // 卸载同时停主动嗅探(拆分第 4 刀重指):注册仍在本侧执行,回调只调 health 工厂暴露的 disposeProbe()
1310
+ // (AbortSignal 取消在飞请求 + 清 probeNextAt 间隔闸表 + 落 probeDisposed 卸载闸,三步操作逐字一致;
1311
+ // 迟到结果不再写健康表,已有被动表随 host 状态一并废弃)。
1312
+ ctx.effect(function () { return function () { disposeProbe() } })
2322
1313
  // Host 侧调度心跳:每 15s 对所有已知会话跑 poolCycle(客户端轮询只是触发器之一,面板关闭/后台节流时照常运转)
2323
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 }) })()
2324
1315
 
@@ -2360,5 +1351,7 @@ export function createDispatch(ctx, state, deps) {
2360
1351
 
2361
1352
  // resumeTarget 一并透出(task-mv6c7cbo):rpc 侧 doIntervene 在无活跃 rec 时靠它细分
2362
1353
  // 「续跑窗口 / 真无 run」两态(纯读 t.runs 零 IO,同一函数两处复用,不另写第二套口径)。
2363
- return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory, resumeTarget: resumeTarget, probeConfiguredModels: probeConfiguredModels, maybeProbeModels: maybeProbeModels }
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 }
2364
1357
  }