dsh-agent-board 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/index.mjs +1 -0
- package/lib/client/board-list.js +81 -14
- package/lib/client/dashboard.js +34 -6
- package/lib/client/kernel.js +47 -8
- package/lib/client/task-detail.js +47 -2
- package/lib/client.js +209 -30
- package/lib/core.mjs +93 -1
- package/lib/dispatch.mjs +19 -143
- package/lib/health.mjs +131 -0
- package/lib/rpc.mjs +26 -5
- package/package.json +2 -2
- package/test/core.test.mjs +313 -0
- package/test/health.test.mjs +155 -0
package/lib/core.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export function vt(d) { return d && typeof d === 'object' && Array.isArray(d.tas
|
|
|
32
32
|
// 5. 裸文件名(无 '/' 且非 '*.ext')只在「另一侧也是裸名」时比 basename 相等;
|
|
33
33
|
// 含 '/' 的具体路径不参与裸名规则(否则 'a/core.mjs' 会误撞裸名 'core.mjs')。
|
|
34
34
|
// 保守倾向:不确定即算冲突(误拦只是晚一轮派发,漏拦会让两个 Worker 互踩)。
|
|
35
|
-
function normTouch(s) {
|
|
35
|
+
export function normTouch(s) {
|
|
36
36
|
var v = String(s).replace(/\\/g, '/').trim()
|
|
37
37
|
while (v.slice(0, 2) === './') v = v.slice(2)
|
|
38
38
|
while (v.length > 1 && v.charAt(v.length - 1) === '/') v = v.slice(0, -1)
|
|
@@ -162,6 +162,35 @@ export function checkParentAuto(d, t) { if (!isb(t)) return null; var p = gpt(t,
|
|
|
162
162
|
export function resolveApply(d, t, sid, status, resolution, note) { var ps = t.status; if (status === 'verifying' && t.pipeline && t.pipeline !== 'full') { status = 'resolved' } t.status = status; t.resolution = resolution || null; t.resolvedAt = new Date().toISOString(); ah(t, ps, status, sid, note); var r = { ok: true, task: t }; if (status === 'verifying' && isb(t)) { var s = gsb(t.parentId, d.tasks); if (s.every(function (x) { return x.status === 'resolved' || x.id === t.id })) { var p = gpt(t, d.tasks); if (p && p.status === 'in-progress') { p.status = 'verifying'; p.resolvedAt = new Date().toISOString(); p.resolution = 'all subtasks done'; ah(p, 'in-progress', 'verifying', 'system', 'auto'); r.parentUpdated = true } } }; return r }
|
|
163
163
|
export function verifyApply(d, t, sid, verdict, comment) { var ps = t.status; if (verdict === 'approved') { t.status = 'resolved'; t.verifiedAt = new Date().toISOString(); t.verifiedBy = sid; delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, ps, 'resolved', sid, 'approved' + (comment ? ': ' + comment : '')) } else { t.status = 'in-progress'; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'in-progress', sid, 'rejected' + (comment ? ': ' + comment : '')) }; var r = { ok: true, task: t }; if (verdict === 'approved' && isb(t)) { var p = checkParentAuto(d, t); if (p) { r.parentUpdated = true } }; return r }
|
|
164
164
|
|
|
165
|
+
// ===== 史诗父卡语义层 =====
|
|
166
|
+
// 父卡自动流转:子任务被派发时(poolCycle 占位 claim 的 dispatch 分支调用),
|
|
167
|
+
// 若父卡 status 为 pending → 父卡转 in-progress(ah 记「首个子任务派发,史诗进入推进态」),
|
|
168
|
+
// 让史诗卡离开待办列、给出「正在推进」的列位置信号;同时打通既有 checkParentAuto
|
|
169
|
+
// (全子任务 resolved → 父 verifying 要求父卡在 in-progress,此前 pending 父卡永远到不了)。
|
|
170
|
+
// draft 父卡不动(草稿是刻意的人工态);已在 in-progress/verifying 的不重放(返回 null)。
|
|
171
|
+
// 返回被推进的父卡(无 parentId / 父卡不存在 / 父卡非 pending → null)。
|
|
172
|
+
export function parentKickOnDispatch(d, t) { if (!isb(t)) return null; var p = gpt(t, d.tasks); if (!p || p.status !== 'pending') return null; p.status = 'in-progress'; ah(p, 'pending', 'in-progress', 'system', '首个子任务派发,史诗进入推进态'); return p }
|
|
173
|
+
|
|
174
|
+
// childStats 聚合(get-tasks 返回体字段,按 tasks 现算零存储):
|
|
175
|
+
// { <parentId>: { total, resolved, active, activeTitle } }
|
|
176
|
+
// 口径:total=该 parentId 的非归档子任务数;resolved=其中 resolved/archived(归档子任务
|
|
177
|
+
// 已在 total 口径被排除,archived 分支保留仅为防御性写明口径);active=in-progress 数;
|
|
178
|
+
// activeTitle=第一个 in-progress 子任务标题(按看板顺序,无则空串)。
|
|
179
|
+
// 无非归档子任务的父卡不出键(史诗归档时子任务级联归档,键自然消失)。
|
|
180
|
+
export function aggregateChildStats(tasks) {
|
|
181
|
+
var out = {}
|
|
182
|
+
var list = Array.isArray(tasks) ? tasks : []
|
|
183
|
+
for (var i = 0; i < list.length; i++) {
|
|
184
|
+
var t = list[i]
|
|
185
|
+
if (!t || !isb(t) || t.status === 'archived') continue
|
|
186
|
+
var s = out[t.parentId] || (out[t.parentId] = { total: 0, resolved: 0, active: 0, activeTitle: '' })
|
|
187
|
+
s.total++
|
|
188
|
+
if (t.status === 'resolved' || t.status === 'archived') s.resolved++
|
|
189
|
+
if (t.status === 'in-progress') { s.active++; if (!s.activeTitle) s.activeTitle = String(t.title || '') }
|
|
190
|
+
}
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
165
194
|
// ===== 输出解析(文本降级路径)=====
|
|
166
195
|
// parseSections: 解析 ## 分段输出为结构化字段(容错:无分段时返回空对象,调用方降级)
|
|
167
196
|
export function parseSections(text) {
|
|
@@ -413,3 +442,66 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
|
|
|
413
442
|
export function isOrphan(d, t, runs, now) {
|
|
414
443
|
return t.status === 'in-progress' && t.claimedBy && t.claimedBy !== d.ownerSession && !(runs && runs[t.id]) && !t.escalation && (now - new Date(t.claimedAt || 0).getTime()) > 120000
|
|
415
444
|
}
|
|
445
|
+
|
|
446
|
+
// ===== 调研门禁(warning 族 + epic 发布预检;proposal n-mutduqaen6q9 收窄采纳)=====
|
|
447
|
+
// 背景:调研上下文(contextFiles/contextNotes)是 Worker 效率的最大变量——缺材料时 Worker
|
|
448
|
+
// 要花 10-15 分钟自行 grep 定位,甚至跑偏方向。这里只放纯函数:建卡 warning(软提示不阻断)
|
|
449
|
+
// 与 epic 发布预检汇总;IO(existsSync)由调用方注入,本体零依赖可单测。
|
|
450
|
+
|
|
451
|
+
// 整树 glob 判定:归一化后以 '/**' 结尾或恰为 '**'('src/**'、'./src/**'、'src\**' 都算)——
|
|
452
|
+
// 整树锁会让同批次所有碰该目录的任务串行化。'src/*'、'src/**/*.mjs' 不算(结尾不是 '/**')。
|
|
453
|
+
export function isTreeGlob(p) { var v = normTouch(p); return v === '**' || /\/\*\*$/.test(v) }
|
|
454
|
+
|
|
455
|
+
// 建卡 warning 族(task_create 工具与 create-task RPC 共用同一口径,软提示不阻断创建):
|
|
456
|
+
// ① description trim 后空白 → 提醒补目标/约束(沿用 E 卡既有文案,逐字不变)
|
|
457
|
+
// ② pipeline∈{full,work} 且 touches 非空且 contextFiles/contextNotes 皆空 → 提醒补调研上下文
|
|
458
|
+
// ③ touches 含整树 glob → 提醒精确到文件级
|
|
459
|
+
// ②③ 的触发前提:pipeline≠'direct' 且 touches 非空(direct 主窗口直接处理、无 touches 不指望调研材料)。
|
|
460
|
+
// 返回 string[];调用方自行合并为一条 warning 字段(可选字段,老调用方无感)。
|
|
461
|
+
export var EMPTY_DESC_WARNING = '任务描述为空——Worker 只能凭标题猜需求,建议补一句目标/约束'
|
|
462
|
+
export function createTaskWarnings(t) {
|
|
463
|
+
var out = []
|
|
464
|
+
if (!t || typeof t !== 'object') return out
|
|
465
|
+
if (!String(t.description || '').trim()) out.push(EMPTY_DESC_WARNING)
|
|
466
|
+
var touches = Array.isArray(t.touches) ? t.touches : []
|
|
467
|
+
if ((t.pipeline || 'full') !== 'direct' && touches.length) {
|
|
468
|
+
var cx = t.context || {}
|
|
469
|
+
var hasFiles = Array.isArray(cx.files) && cx.files.length > 0
|
|
470
|
+
var hasNotes = !!(cx.notes && String(cx.notes).trim())
|
|
471
|
+
if (!hasFiles && !hasNotes) out.push('未附调研上下文(contextFiles/contextNotes)——Worker 将自行 grep 定位,建议补上预研文件路径或勾选无需调研')
|
|
472
|
+
if (touches.some(isTreeGlob)) out.push('touches 含整树 glob 会串行化整个批次——修复类任务建议精确到文件级')
|
|
473
|
+
}
|
|
474
|
+
return out
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// epic 发布预检:父卡 publish 时对其子任务做轻量调研注入预检。
|
|
478
|
+
// 口径:只看 pipeline≠direct 的非归档子任务;「无调研注入」= context.files/context.notes 皆空,
|
|
479
|
+
// 或 files 列了路径但全部不存在(exists 回调判定,锚点 :L 段先剥掉再查——存在性是对文件而言的)。
|
|
480
|
+
// exists(path) 由调用方注入(fs.existsSync + 会话工作区相对解析包装);缺省时退化为只查字段有无。
|
|
481
|
+
// 返回 { total, missing: [{id, title, reason}] };total=参与预检的子任务数(供汇总文案 N/M)。
|
|
482
|
+
export function epicPrecheck(tasks, parentId, exists) {
|
|
483
|
+
var kids = gsb(parentId, Array.isArray(tasks) ? tasks : []).filter(function (x) { return x && x.status !== 'archived' && x.pipeline !== 'direct' })
|
|
484
|
+
var missing = []
|
|
485
|
+
for (var i = 0; i < kids.length; i++) {
|
|
486
|
+
var k = kids[i]
|
|
487
|
+
var cx = (k && k.context) || {}
|
|
488
|
+
var files = Array.isArray(cx.files) ? cx.files.filter(function (p) { return typeof p === 'string' && p.trim() }) : []
|
|
489
|
+
var hasNotes = !!(cx.notes && String(cx.notes).trim())
|
|
490
|
+
var reason = ''
|
|
491
|
+
if (!files.length && !hasNotes) reason = '无 contextFiles/contextNotes'
|
|
492
|
+
else if (files.length && typeof exists === 'function') {
|
|
493
|
+
var anyExist = files.some(function (p) { try { return !!exists(parseAnchorPath(p).file) } catch (_) { return false } })
|
|
494
|
+
if (!anyExist) reason = '预研文件路径全部不存在(注入将全是读取失败)'
|
|
495
|
+
}
|
|
496
|
+
if (reason) missing.push({ id: String(k.id || ''), title: String(k.title || '').slice(0, 40), reason: reason })
|
|
497
|
+
}
|
|
498
|
+
return { total: kids.length, missing: missing }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 汇总文案:全部有材料 → 空串(不打扰);否则 'epic 发布预检:N 个子任务中 M 个无调研注入:…'。
|
|
502
|
+
// 列表最多列 5 条(id「title」(原因)),超出折叠为「等」——pushSysNote 队列单条不宜过长。
|
|
503
|
+
export function epicPrecheckNote(pre) {
|
|
504
|
+
if (!pre || !Array.isArray(pre.missing) || !pre.missing.length) return ''
|
|
505
|
+
var list = pre.missing.slice(0, 5).map(function (m) { return m.id + '「' + m.title + '」' + (m.reason ? '(' + m.reason + ')' : '') }).join('、')
|
|
506
|
+
return 'epic 发布预检:' + pre.total + ' 个子任务中 ' + pre.missing.length + ' 个无调研注入:' + list + (pre.missing.length > 5 ? ' 等' : '') + '——建议先补 contextFiles/contextNotes 再发布'
|
|
507
|
+
}
|
package/lib/dispatch.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import * as core from './core.mjs'
|
|
6
6
|
import { readRunUsage } from './usage.mjs'
|
|
7
7
|
import { TEAM_SPLIT_RULE, pushRejectLesson } from './policy.mjs'
|
|
8
|
-
const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseAnchorPath, sliceLines, buildFileOutline, LESSON_RECALL_HINT } = core
|
|
8
|
+
const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseAnchorPath, sliceLines, buildFileOutline, parentKickOnDispatch, LESSON_RECALL_HINT } = core
|
|
9
9
|
|
|
10
10
|
export function createDispatch(ctx, state, deps) {
|
|
11
11
|
const fs = ctx.fs
|
|
@@ -117,10 +117,11 @@ export function createDispatch(ctx, state, deps) {
|
|
|
117
117
|
try {
|
|
118
118
|
await mutateLocked(sid, function (d) {
|
|
119
119
|
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
120
|
-
if (!t) return
|
|
120
|
+
if (!t) return null // 找不到任务:返回 null 不写盘
|
|
121
121
|
if (!Array.isArray(t.runs)) t.runs = []
|
|
122
122
|
t.runs.push({ role: role, id: runId, at: new Date().toISOString(), model: model || '', outcome: 'running', hardMin: hardMin || 120 })
|
|
123
123
|
if (t.runs.length > 20) t.runs = t.runs.slice(-20)
|
|
124
|
+
return { ok: true } // mutateLocked 契约:回调返回 null/undefined 则跳过写盘——必须显式返回非空值,否则 runs 永不落盘
|
|
124
125
|
})
|
|
125
126
|
} catch (e) { console.error('[task-board] recordRunHistory failed:', String(e)) }
|
|
126
127
|
}
|
|
@@ -128,10 +129,11 @@ export function createDispatch(ctx, state, deps) {
|
|
|
128
129
|
try {
|
|
129
130
|
await mutateLocked(sid, function (d) {
|
|
130
131
|
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
131
|
-
if (!t || !Array.isArray(t.runs)) return
|
|
132
|
+
if (!t || !Array.isArray(t.runs)) return null // 找不到任务/无 runs:返回 null 不写盘
|
|
132
133
|
for (var i = t.runs.length - 1; i >= 0; i--) {
|
|
133
134
|
if (t.runs[i].id === runId) { t.runs[i].outcome = outcome; t.runs[i].endedAt = new Date().toISOString(); break }
|
|
134
135
|
}
|
|
136
|
+
return { ok: true } // 同上:显式返回非空值触发写盘
|
|
135
137
|
})
|
|
136
138
|
} catch (e) { console.error('[task-board] closeRunHistory failed:', String(e)) }
|
|
137
139
|
}
|
|
@@ -145,7 +147,13 @@ export function createDispatch(ctx, state, deps) {
|
|
|
145
147
|
if (role === 'verifier') { modelOverride = (typeof dsnap.verifierModel === 'string' && dsnap.verifierModel.trim()) ? dsnap.verifierModel.trim() : ''; if (modelOverride && badModels[modelKey(sid, modelOverride)]) { console.error('[task-board] model ' + modelOverride + ' circuited, using parent model'); modelOverride = '' } }
|
|
146
148
|
else if (role === 'worker') { modelOverride = (typeof dsnap.workerModel === 'string' && dsnap.workerModel.trim()) ? dsnap.workerModel.trim() : ''; if (modelOverride && badModels[modelKey(sid, modelOverride)]) { console.error('[task-board] model ' + modelOverride + ' circuited, using parent model'); modelOverride = '' } }
|
|
147
149
|
var pack = ''
|
|
148
|
-
try { pack = await readContextPack(sid, t) } catch (e) {
|
|
150
|
+
try { pack = await readContextPack(sid, t) } catch (e) {
|
|
151
|
+
console.error('[task-board] context pack read failed:', String(e))
|
|
152
|
+
// 调研门禁④:读包失败落卡(t.lastError,复用详情页「最近失败」行展示机制),不再只沉在
|
|
153
|
+
// host 控制台——主窗口排查「Worker 为什么没拿到预研材料」不用翻日志。
|
|
154
|
+
// mutateLocked 契约:回调返回 null/undefined 跳过写盘——找到任务才返回非空值;skipKick 免一次无谓 poolCycle。
|
|
155
|
+
try { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === t.id }); if (!t2) return null; t2.lastError = ('contextPack 读取失败: ' + String(e)).slice(0, 300); return t2 }, true) } catch (_) {}
|
|
156
|
+
}
|
|
149
157
|
// user prompt 只留一行指引,内容走上下文注入区块
|
|
150
158
|
var packNote = pack ? '本任务附带主窗口预研文件,已通过「上下文注入」区提供(含文件完整内容),直接基于其内容工作,不要重复读取这些文件。' : ''
|
|
151
159
|
var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: role === 'worker' ? buildWorkerPrompt(t, packNote, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, packNote) }], parent: parent, signal: makeSignal() }
|
|
@@ -250,6 +258,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
250
258
|
// 工具通道已处理(board_report 已推进到 verifying/resolved 或挂了 escalation)→ 只收尾
|
|
251
259
|
if (t.status !== 'in-progress' || t.escalation) return { task: t, already: true }
|
|
252
260
|
if (failed) {
|
|
261
|
+
// 失败原因落卡(截断 300):卡片详情页可直接查看最近失败原因,排查三连败不再靠猜
|
|
262
|
+
t.lastError = String(errText || '').slice(0, 300)
|
|
253
263
|
t.retryCount = (t.retryCount || 0) + 1
|
|
254
264
|
if (t.retryCount >= 3) { var ps = t.status; t.status = 'blocked'; ah(t, ps, 'blocked', String(rec.run.id), 'worker 失败 x' + t.retryCount + '(' + String(errText).slice(0, 120) + '),待人工介入'); return { task: t, blocked: true } }
|
|
255
265
|
var ps2 = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps2, 'pending', String(rec.run.id), 'worker 失败(' + String(errText).slice(0, 80) + '),重新排队 (' + t.retryCount + '/3)')
|
|
@@ -264,7 +274,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
264
274
|
}
|
|
265
275
|
// 文本降级路径:分段格式上报
|
|
266
276
|
var secs = parseSections(output)
|
|
267
|
-
delete t.retryCount; delete t.stuckSince
|
|
277
|
+
delete t.retryCount; delete t.stuckSince; delete t.lastError // 成功路径:失败计数/卡死标记/最近失败原因一并清除
|
|
268
278
|
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.run.id) }
|
|
269
279
|
resolveApply(d, t, String(rec.run.id), 'verifying', output || 'Worker 完成', 'worker 文本上报完成')
|
|
270
280
|
return { task: t }
|
|
@@ -286,6 +296,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
286
296
|
var trimmed = (output || '').trim()
|
|
287
297
|
var vm = trimmed.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
|
|
288
298
|
if (failed || !vm) {
|
|
299
|
+
// 失败原因落卡(截断 300):verifier 故障/输出无法判定的原因留在卡片上供排查
|
|
300
|
+
t.lastError = String(errText || 'verifier 未给出有效结论(输出格式异常)').slice(0, 300)
|
|
289
301
|
// 失败/空输出/无法判定:verifyRetries 计数,>=3 转人工验收(deliverable 已完成,是 verifier 故障不是任务故障)
|
|
290
302
|
if (failed && rec.model) { badModels[modelKey(sid, rec.model)] = true; pushSysNote(sid, '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型') }
|
|
291
303
|
t.verifyRetries = (t.verifyRetries || 0) + 1
|
|
@@ -295,7 +307,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
295
307
|
}
|
|
296
308
|
var approved = vm[1].toUpperCase() === 'APPROVED'
|
|
297
309
|
var vsecs = parseSections(trimmed)
|
|
298
|
-
delete t.stuckSince; delete t.verifyRetries
|
|
310
|
+
delete t.stuckSince; delete t.verifyRetries; delete t.lastError // 成功给出结论:卡死标记/重试计数/最近失败原因一并清除
|
|
299
311
|
t.verification = { verdict: approved ? 'approved' : 'rejected', summary: vsecs.verifySummary || trimmed.slice(0, 600), checks: vsecs.checks || '', at: new Date().toISOString(), by: String(rec.run.id) }
|
|
300
312
|
verifyApply(d, t, String(rec.run.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
|
|
301
313
|
if (!approved) {
|
|
@@ -316,142 +328,6 @@ export function createDispatch(ctx, state, deps) {
|
|
|
316
328
|
kickCycle(sid)
|
|
317
329
|
}
|
|
318
330
|
|
|
319
|
-
// 歧义上报通知:任何模式都通知主窗口(escalation 需要人工裁决,不能静默吞掉)
|
|
320
|
-
// 25s 去抖投递:主窗口 turn 进行中时 followup 只在宿主侧排队,送达时任务常已被裁决/归档(过期回声);
|
|
321
|
-
// 排队无法撤回,插件侧唯一可行的方案就是延迟 + 投递前重查看板。
|
|
322
|
-
// escNotifyTimers 存每个任务最新一次调度:同一任务再次上报即顶替旧调度(旧回调身份不匹配 → 静默丢弃);
|
|
323
|
-
// 投递时才读 escalation.question,所以连续多次上报只会收到一条、且一定是最新疑问。
|
|
324
|
-
var escNotifyTimers = {}
|
|
325
|
-
function deliverEscalation(sid, taskId) {
|
|
326
|
-
rt(sid).then(function (d) {
|
|
327
|
-
var t = null
|
|
328
|
-
var list = (d && d.tasks) || []
|
|
329
|
-
for (var i = 0; i < list.length; i++) { if (list[i].id === taskId) { t = list[i]; break } }
|
|
330
|
-
// escalation 已消失(已被裁决)或任务已 resolved/archived → 通知已过期,静默跳过(history 不记)
|
|
331
|
-
if (!t || !t.escalation || t.status === 'resolved' || t.status === 'archived') return
|
|
332
|
-
var root = rootForSession(sid)
|
|
333
|
-
if (!root) return
|
|
334
|
-
try { root.followup(makeMsg('⚠️ [任务看板] Worker 上报歧义,等待裁决:\n\n任务: ' + t.title + ' (' + t.id + ')\n\n疑问:\n' + String(t.escalation.question || '').slice(0, 1500) + '\n\n请在看板详情页裁决,或直接回复指示。裁决后会有新 Worker 带着裁决答案接手。\n\n(若收到时任务已被裁决或归档,说明本通知投递晚于处理——先用 task_list/get-tasks 核对状态,勿重复裁决。)', 'notice', '任务待裁决: ' + t.title)) } catch (e) { console.error('[task-board] escalate notify failed:', String(e)) }
|
|
335
|
-
}).catch(function (e) { console.error('[task-board] escalate notify failed:', String(e)) })
|
|
336
|
-
}
|
|
337
|
-
function notifyMainWindow(sid, t) {
|
|
338
|
-
var tm = ctx.timer
|
|
339
|
-
if (!tm) { deliverEscalation(sid, t.id); return } // timer 不可用 → 直接投递(保持原即时行为)
|
|
340
|
-
var key = sid + ':' + t.id
|
|
341
|
-
var mine = tm.timeout(25000)
|
|
342
|
-
escNotifyTimers[key] = mine
|
|
343
|
-
mine.then(function () {
|
|
344
|
-
if (escNotifyTimers[key] !== mine) return // 已被该任务更新的一次上报顶替 → 丢弃,避免重复通知
|
|
345
|
-
delete escNotifyTimers[key]
|
|
346
|
-
deliverEscalation(sid, t.id)
|
|
347
|
-
}).catch(function () {}) // 插件销毁时 timeout 会 reject("Context has been disposed"),静默吞掉
|
|
348
|
-
}
|
|
349
|
-
function maybeNotify(sid, task) { if (task && task.escalation) { notifyMainWindow(sid, task) } }
|
|
350
|
-
|
|
351
|
-
// ===== 任务回执通知(批量聚合 + 空闲门控):派发执行的任务在 完成/阻塞 时通知主窗口 =====
|
|
352
|
-
// 只通知派发执行的任务(isDispatched),主窗口自己手动处理的任务不回执(自己干的自己知道)。
|
|
353
|
-
// 批量聚合:任务多时每任务一条 followup 会把主窗口 turn 队列打满(用户输入排队等回执处理完才刷新),
|
|
354
|
-
// 改为 45s 窗口(或满 5 条)聚合为一条摘要;发送前等主窗口空闲,不打断对话。
|
|
355
|
-
var receiptBuf = {}
|
|
356
|
-
// 回执幂等去重表:key = 任务id + 类别 + 完成事件指纹(deliverable/verification/resolvedAt/末条history 时间戳)。
|
|
357
|
-
// 同一完成事件被任何路径(工具直报/run 结算/未来回归)重复通知时指纹一致 → 吞掉;
|
|
358
|
-
// 驳回后重做完成 → 时间戳全换新 → 指纹不同 → 正常回执。
|
|
359
|
-
var receiptedKeys = {}
|
|
360
|
-
function notifyTaskDone(sid, t, kind) {
|
|
361
|
-
if (!t || !isDispatched(sid, t.claimedBy)) return
|
|
362
|
-
var lastHist = (t.history && t.history.length) ? String(t.history[t.history.length - 1].timestamp || '') : ''
|
|
363
|
-
var stamp = [kind, (t.deliverable && t.deliverable.at) || '', (t.verification && t.verification.at) || '', t.resolvedAt || '', lastHist].join('|')
|
|
364
|
-
var key = t.id + ':' + stamp
|
|
365
|
-
if (receiptedKeys[key]) return
|
|
366
|
-
var rkeys = Object.keys(receiptedKeys)
|
|
367
|
-
if (rkeys.length > 512) { var rnow = Date.now(); for (var ri = 0; ri < rkeys.length; ri++) { if (rnow - receiptedKeys[rkeys[ri]] > 3600000) delete receiptedKeys[rkeys[ri]] } }
|
|
368
|
-
receiptedKeys[key] = Date.now()
|
|
369
|
-
var buf = receiptBuf[sid] || (receiptBuf[sid] = { items: [], timer: null })
|
|
370
|
-
var lastNote = (t.history && t.history.length) ? String(t.history[t.history.length - 1].note || '') : ''
|
|
371
|
-
buf.items.push({ kind: kind, title: t.title, id: t.id, summary: (t.deliverable && t.deliverable.summary) || '', note: lastNote })
|
|
372
|
-
if (buf.items.length >= 5) { flushReceipts(sid); return }
|
|
373
|
-
if (!buf.timer) {
|
|
374
|
-
var tm = ctx.timer
|
|
375
|
-
if (tm) { var captured = buf; buf.timer = tm.timeout(45000).then(function () { if (receiptBuf[sid] === captured) flushReceipts(sid) }).catch(function () {}) }
|
|
376
|
-
else flushReceipts(sid)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
// 系统级异常通知队列(易失,随回执冲刷):模型熔断/spawn 失败/孤儿回收/看门狗标记
|
|
380
|
-
var sysNotesBuf = {}
|
|
381
|
-
// taskId 可选:告警类通知(软超时提醒等)语义只对「任务仍在执行中」成立,
|
|
382
|
-
// 带上任务 id 后 flush 投递前可重读看板校验,任务已落定的过期告警直接丢弃
|
|
383
|
-
function pushSysNote(sid, text, taskId) {
|
|
384
|
-
var arr = (sysNotesBuf[sid] = sysNotesBuf[sid] || [])
|
|
385
|
-
arr.push({ text: text, at: new Date().toISOString(), taskId: taskId || null })
|
|
386
|
-
if (arr.length > 10) arr.splice(0, arr.length - 10)
|
|
387
|
-
}
|
|
388
|
-
function flushReceipts(sid) {
|
|
389
|
-
var buf = receiptBuf[sid]
|
|
390
|
-
var notes = sysNotesBuf[sid] || []
|
|
391
|
-
if ((!buf || !buf.items.length) && !notes.length) return
|
|
392
|
-
receiptBuf[sid] = null
|
|
393
|
-
sysNotesBuf[sid] = []
|
|
394
|
-
var root = rootForSession(sid); if (!root) return
|
|
395
|
-
var items = (buf && buf.items) ? buf.items.slice() : []
|
|
396
|
-
function noteOf(status) { return status === 'verifying' ? '验证中' : '进行中' }
|
|
397
|
-
// 组装投递文本(入参已是过滤后的存活项,避免用已丢弃项的计数)
|
|
398
|
-
function composeText(items, notes) {
|
|
399
|
-
var done = [], blocked = []
|
|
400
|
-
for (var i = 0; i < items.length; i++) { (items[i].kind === 'resolved' ? done : blocked).push(items[i]) }
|
|
401
|
-
var lines = [done.length || blocked.length ? '📋 [任务看板] 回执摘要(' + items.length + ' 条)' : '📋 [任务看板] 系统通知', '']
|
|
402
|
-
if (done.length) {
|
|
403
|
-
lines.push('✅ 完成 ' + done.length + ' 个:')
|
|
404
|
-
for (var j = 0; j < done.length && j < 8; j++) lines.push(' · ' + done[j].title + ' (' + done[j].id + ')' + (done[j].summary ? ' — ' + done[j].summary.slice(0, 120) : ''))
|
|
405
|
-
}
|
|
406
|
-
if (blocked.length) {
|
|
407
|
-
lines.push('🛑 阻塞 ' + blocked.length + ' 个(需关注):')
|
|
408
|
-
for (var k = 0; k < blocked.length && k < 8; k++) lines.push(' · ' + blocked[k].title + ' (' + blocked[k].id + ')' + (blocked[k].note ? ' — ' + blocked[k].note.slice(0, 150) : ''))
|
|
409
|
-
}
|
|
410
|
-
if (notes.length) {
|
|
411
|
-
lines.push('', '⚠️ 系统异常 ' + notes.length + ' 条:')
|
|
412
|
-
for (var n = 0; n < notes.length && n < 8; n++) lines.push(' · ' + notes[n].text)
|
|
413
|
-
}
|
|
414
|
-
lines.push('', '可用 task_list 查看全部;阻塞项可在看板拖回待办重新投放。')
|
|
415
|
-
return lines.join('\n')
|
|
416
|
-
}
|
|
417
|
-
// 投递前状态过滤:入队到投递之间隔着 45s 聚合窗口 + 等主窗口空闲(最长 5 分钟),
|
|
418
|
-
// 期间任务可能已经完成/归档——过期告警与死回执会误报(实测软超时告警 6/6 全误报)。
|
|
419
|
-
// 所以 send 之前重读一次看板,按任务现状决定丢哪些项。
|
|
420
|
-
function deliver() {
|
|
421
|
-
return Promise.resolve().then(function () { return rt(sid) }).catch(function () { return null }).then(function (snap) {
|
|
422
|
-
var tasks = (snap && snap.tasks) || []
|
|
423
|
-
function findTask(id) { for (var i = 0; i < tasks.length; i++) { if (tasks[i].id === id) return tasks[i] } return null }
|
|
424
|
-
// a. 带 taskId 的告警项:任务状态不在 in-progress/verifying 即已落定 → 丢弃;
|
|
425
|
-
// 保留的项在文本前标注投递时状态(读到的是发送瞬间的真实状态,人可据此判断时效)
|
|
426
|
-
var keptNotes = []
|
|
427
|
-
for (var i = 0; i < notes.length; i++) {
|
|
428
|
-
var nt = notes[i]
|
|
429
|
-
if (nt.taskId) {
|
|
430
|
-
var t = findTask(nt.taskId)
|
|
431
|
-
if (!t || (t.status !== 'in-progress' && t.status !== 'verifying')) continue
|
|
432
|
-
keptNotes.push({ text: '(投递时状态:' + noteOf(t.status) + ')' + nt.text })
|
|
433
|
-
} else keptNotes.push(nt)
|
|
434
|
-
}
|
|
435
|
-
// b. 回执项:任务已 archived(人已手动归档 = 已知悉)→ 丢弃;
|
|
436
|
-
// resolved/blocked 保留(回执是主通道,任务查不到也保留,不能因读盘失败丢回执)
|
|
437
|
-
var keptItems = []
|
|
438
|
-
for (var j = 0; j < items.length; j++) {
|
|
439
|
-
var it = items[j]
|
|
440
|
-
var tt = findTask(it.id)
|
|
441
|
-
if (tt && tt.status === 'archived') continue
|
|
442
|
-
keptItems.push(it)
|
|
443
|
-
}
|
|
444
|
-
// c. 过滤后全空 → 不再打扰主窗口
|
|
445
|
-
if (!keptItems.length && !keptNotes.length) return
|
|
446
|
-
try { root.followup(makeMsg(composeText(keptItems, keptNotes), 'recall')) } catch (e) { console.error('[task-board] receipt flush failed:', String(e)) }
|
|
447
|
-
})
|
|
448
|
-
}
|
|
449
|
-
if (typeof root.whenIdle === 'function') {
|
|
450
|
-
var waited = withTimeout(root.whenIdle(), 300000, 'receipt-idle-wait') // 最多等 5 分钟,超时也发(不能丢回执)
|
|
451
|
-
Promise.resolve(waited).then(deliver, deliver).catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
|
|
452
|
-
} else deliver().catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
|
|
453
|
-
}
|
|
454
|
-
|
|
455
331
|
// ===== 派发周期(15s 心跳 + 写入后 kickCycle 触发)=====
|
|
456
332
|
async function poolCycle(sid) {
|
|
457
333
|
var info = []
|
|
@@ -488,7 +364,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
488
364
|
// worker 派发仅 auto;verifier 派发两种模式都跑(manual 模式主窗口 claim 做完的 full 档任务需要验收)
|
|
489
365
|
var capW = isAuto ? Math.max(0, c.maxWorkers - activeW) : 0
|
|
490
366
|
var picked = pickDispatch(d, capW, Math.max(0, c.maxVerifiers - activeV), runs)
|
|
491
|
-
picked.pendings.forEach(function (t) { claimApply(d, t, 'spawn-pending', 'dispatch'); toSpawn.push({ role: 'worker', t: t }); info.push('dispatch ' + t.id) })
|
|
367
|
+
picked.pendings.forEach(function (t) { claimApply(d, t, 'spawn-pending', 'dispatch'); if (parentKickOnDispatch(d, t)) info.push('epic-kick ' + t.parentId); toSpawn.push({ role: 'worker', t: t }); info.push('dispatch ' + t.id) })
|
|
492
368
|
picked.verifs.forEach(function (t) { t.verifierRun = 'spawn-pending'; t.verifierRunAt = new Date().toISOString(); toSpawn.push({ role: 'verifier', t: t }); info.push('verify ' + t.id) })
|
|
493
369
|
// touches 文件级排他展示态:被拦候选写 t.waitingForTouches = [持有者任务id...],
|
|
494
370
|
// 未被拦/已派发/已落定的任务清除该字段(每心跳刷新的 UI 展示态,不参与任何派发逻辑,
|
package/lib/health.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// dsh-agent-board — 架构自省 L1(lib/health.mjs)
|
|
2
|
+
// 纯函数、零状态、零 IO:从任务数组现算「架构健康提示」,不落盘、不通知、不依赖任何 ctx。
|
|
3
|
+
// 数据源只用任务持久化字段(createdAt/claimedAt/resolvedAt/touches/rejectCount)。
|
|
4
|
+
// 窗口化(近 50 张卡)而非累计:架构优化落地后老数据自动滑出窗口不再报警——
|
|
5
|
+
// 健康区同时是「拆分是否有效」的客观验收证据。
|
|
6
|
+
// 信号源不做裁判:只产提示文案,决策永远是人。
|
|
7
|
+
import { normTouch } from './core.mjs'
|
|
8
|
+
|
|
9
|
+
// ===== 阈值常量(全部置顶,可按实际使用手感调整;只影响之后的计算,不追溯老数据)=====
|
|
10
|
+
var WINDOW_SIZE = 50 // 统计窗口:按 createdAt 取最近 N 张卡(含归档)
|
|
11
|
+
var TOUCH_HOT_MIN = 8 // 信号 a:单路径 touches 声明次数阈值
|
|
12
|
+
var TOUCH_HOT_RATIO = 0.4 // 信号 a:声明次数占窗口内「有 touches 任务」的比例阈值
|
|
13
|
+
var SERIAL_MIN_TOUCH = 5 // 信号 b:有 touches 任务的最小样本数(不足则不判定,防小样本噪声)
|
|
14
|
+
var SERIAL_RATIO = 2 // 信号 b:有 touches 任务滞留中位数 > 无 touches 任务的 N 倍
|
|
15
|
+
var P90_LIMIT_MIN = 45 // 信号 c:resolved 任务执行时长(claimedAt→resolvedAt)p90 阈值(分钟)
|
|
16
|
+
var REJECT_HOT_MIN = 2 // 信号 d:单路径累计驳回次数阈值
|
|
17
|
+
var MAX_HINTS = 3 // 返回提示上限(warn 信号优先排列)
|
|
18
|
+
|
|
19
|
+
// 毫秒 → 分钟(四舍五入取整,仅供文案展示)
|
|
20
|
+
function toMin(ms) { return Math.round(ms / 60000) }
|
|
21
|
+
|
|
22
|
+
// 中位数(输入任意序,内部复制排序;空数组返回 null)
|
|
23
|
+
function medianOf(nums) {
|
|
24
|
+
if (!nums.length) return null
|
|
25
|
+
var v = nums.slice().sort(function (a, b) { return a - b })
|
|
26
|
+
var n = v.length
|
|
27
|
+
return n % 2 ? v[(n - 1) / 2] : (v[n / 2 - 1] + v[n / 2]) / 2
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// p 分位数(near-rank:升序后取 ceil(p*n)-1 位;空数组返回 null)
|
|
31
|
+
function percentileOf(nums, p) {
|
|
32
|
+
if (!nums.length) return null
|
|
33
|
+
var v = nums.slice().sort(function (a, b) { return a - b })
|
|
34
|
+
var i = Math.ceil(p * v.length) - 1
|
|
35
|
+
if (i < 0) i = 0
|
|
36
|
+
return v[i]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// pending 滞留代理:createdAt →(claimedAt 优先,否则 resolvedAt)。无法计算返回 -1。
|
|
40
|
+
function waitMsOf(t) {
|
|
41
|
+
var end = t.claimedAt || t.resolvedAt
|
|
42
|
+
if (!end || !t.createdAt) return -1
|
|
43
|
+
var ms = Date.parse(end) - Date.parse(t.createdAt)
|
|
44
|
+
return (isNaN(ms) || ms < 0) ? -1 : ms
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 执行时长:claimedAt → resolvedAt(纯干活耗时,不含排队;task-mutdnitw 口径修正)。
|
|
48
|
+
// 无 claimedAt(手工/direct 卡没有领取动作)或无法计算返回 -1,不参与聚合。
|
|
49
|
+
function durationMsOf(t) {
|
|
50
|
+
if (!t.resolvedAt || !t.claimedAt) return -1
|
|
51
|
+
var ms = Date.parse(t.resolvedAt) - Date.parse(t.claimedAt)
|
|
52
|
+
return (isNaN(ms) || ms < 0) ? -1 : ms
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 任务 touches 归一化(与 core.mjs normTouch 同款口径)+ 单任务内去重;无 touches 返回 []
|
|
56
|
+
function normPathsOf(t) {
|
|
57
|
+
var raw = Array.isArray(t.touches) ? t.touches : []
|
|
58
|
+
var seen = {}, out = []
|
|
59
|
+
for (var i = 0; i < raw.length; i++) {
|
|
60
|
+
if (typeof raw[i] !== 'string') continue
|
|
61
|
+
var p = normTouch(raw[i])
|
|
62
|
+
if (p && !seen[p]) { seen[p] = true; out.push(p) }
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 取计数最高的路径;并列时取字典序较小者保证确定性。返回 [path, count],空表返回 ['', 0]
|
|
68
|
+
function topPath(counts) {
|
|
69
|
+
var best = '', bestN = 0
|
|
70
|
+
for (var p in counts) {
|
|
71
|
+
var n = counts[p]
|
|
72
|
+
if (n > bestN || (n === bestN && n > 0 && p < best)) { best = p; bestN = n }
|
|
73
|
+
}
|
|
74
|
+
return [best, bestN]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 主入口:tasks = 看板任务数组(含归档)。返回 [{level:'warn'|'info', text}],最多 MAX_HINTS 条,无命中返回 []。
|
|
78
|
+
export function computeHealthHints(tasks) {
|
|
79
|
+
var all = Array.isArray(tasks) ? tasks.slice() : []
|
|
80
|
+
if (!all.length) return []
|
|
81
|
+
// 窗口:按 createdAt 倒序取最近 WINDOW_SIZE 张(缺 createdAt 视为空串,排在最老一侧)
|
|
82
|
+
all.sort(function (a, b) { return String((b && b.createdAt) || '').localeCompare(String((a && a.createdAt) || '')) })
|
|
83
|
+
var win = all.slice(0, WINDOW_SIZE)
|
|
84
|
+
|
|
85
|
+
// 单趟遍历收集各信号所需的分组数据
|
|
86
|
+
var declCounts = {} // 信号 a:路径 → 声明次数
|
|
87
|
+
var rejCounts = {} // 信号 d:路径 → 累计驳回次数
|
|
88
|
+
var waitWith = [] // 信号 b:有 touches 任务的滞留毫秒
|
|
89
|
+
var waitWithout = [] // 信号 b:无 touches 任务的滞留毫秒
|
|
90
|
+
var withTouchN = 0 // 窗口内有 touches 的任务数(信号 a 占比分母)
|
|
91
|
+
var durations = [] // 信号 c:resolved 任务执行时长毫秒(claimedAt→resolvedAt)
|
|
92
|
+
for (var i = 0; i < win.length; i++) {
|
|
93
|
+
var t = win[i] || {}
|
|
94
|
+
var paths = normPathsOf(t)
|
|
95
|
+
if (paths.length) {
|
|
96
|
+
withTouchN++
|
|
97
|
+
for (var j = 0; j < paths.length; j++) declCounts[paths[j]] = (declCounts[paths[j]] || 0) + 1
|
|
98
|
+
}
|
|
99
|
+
var rc = t.rejectCount || 0
|
|
100
|
+
if (rc > 0) { for (var k = 0; k < paths.length; k++) rejCounts[paths[k]] = (rejCounts[paths[k]] || 0) + rc }
|
|
101
|
+
var w = waitMsOf(t)
|
|
102
|
+
if (w >= 0) { if (paths.length) waitWith.push(w); else waitWithout.push(w) }
|
|
103
|
+
var dur = durationMsOf(t)
|
|
104
|
+
if (dur >= 0) durations.push(dur)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
var hints = []
|
|
108
|
+
// --- 信号 a:touches 声明热度(某路径被高频声明 = 架构收敛点失控,该拆)---
|
|
109
|
+
var hot = topPath(declCounts)
|
|
110
|
+
if (hot[1] >= TOUCH_HOT_MIN && withTouchN > 0 && hot[1] / withTouchN >= TOUCH_HOT_RATIO) {
|
|
111
|
+
hints.push({ level: 'warn', text: hot[0] + ' 在近 ' + win.length + ' 张卡中被 ' + hot[1] + ' 次声明 touches(占比 ' + Math.round(hot[1] / withTouchN * 100) + '%)——考虑拆分模块' })
|
|
112
|
+
}
|
|
113
|
+
// --- 信号 d:驳回热点(按 touches 路径聚合 rejectCount = 质量脆弱区)---
|
|
114
|
+
var rej = topPath(rejCounts)
|
|
115
|
+
if (rej[1] >= REJECT_HOT_MIN) {
|
|
116
|
+
hints.push({ level: 'warn', text: rej[0] + ' 相关任务被驳回 ' + rej[1] + ' 次——质量脆弱区,建议审查该模块' })
|
|
117
|
+
}
|
|
118
|
+
// --- 信号 b:串行代价代理(有 touches 任务滞留中位数显著更长 = 并行度受锁限制)---
|
|
119
|
+
if (waitWith.length >= SERIAL_MIN_TOUCH && waitWithout.length > 0) {
|
|
120
|
+
var medW = medianOf(waitWith), medWo = medianOf(waitWithout)
|
|
121
|
+
if (medW > medWo * SERIAL_RATIO) {
|
|
122
|
+
hints.push({ level: 'info', text: '带 touches 任务平均等待明显更长(中位数 ' + toMin(medW) + 'min vs ' + toMin(medWo) + 'min),并行度受锁限制' })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// --- 信号 c:执行时长 p90(resolved 任务执行时长尾部抬升 = 粒度/架构成本恶化)---
|
|
126
|
+
var p90 = percentileOf(durations, 0.9)
|
|
127
|
+
if (p90 !== null && p90 > P90_LIMIT_MIN * 60000) {
|
|
128
|
+
hints.push({ level: 'info', text: '任务执行时长 p90 已达 ' + toMin(p90) + 'min——超长任务占比升高,考虑拆分粒度或检查架构热点' })
|
|
129
|
+
}
|
|
130
|
+
return hints.slice(0, MAX_HINTS)
|
|
131
|
+
}
|