dsh-agent-board 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -10
- package/index.mjs +10 -3
- package/lib/client/board-list.js +47 -5
- package/lib/client/dashboard.js +151 -8
- package/lib/client/kernel.js +61 -15
- package/lib/client/task-detail.js +161 -9
- package/lib/client.js +420 -37
- package/lib/core.mjs +402 -21
- package/lib/dispatch.mjs +164 -12
- package/lib/health.mjs +43 -11
- package/lib/notify.mjs +42 -12
- package/lib/policy.mjs +17 -1
- package/lib/rpc.mjs +71 -13
- package/lib/session.mjs +24 -3
- package/lib/store.mjs +2 -1
- package/lib/usage.mjs +84 -3
- package/package.json +1 -1
- package/test/core.test.mjs +1906 -38
- package/test/health.test.mjs +86 -4
package/lib/dispatch.mjs
CHANGED
|
@@ -4,14 +4,19 @@
|
|
|
4
4
|
// Team 模式 systemPrompt 引导段 / 预研文件上下文注入通道(packByChild + pendingPacks 首轮竞速认领)。
|
|
5
5
|
import * as core from './core.mjs'
|
|
6
6
|
import { readRunUsage } from './usage.mjs'
|
|
7
|
-
import {
|
|
8
|
-
const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseAnchorPath, sliceLines, buildFileOutline, parentKickOnDispatch, LESSON_RECALL_HINT } = core
|
|
7
|
+
import { splitRuleOf, pushRejectLesson } from './policy.mjs'
|
|
8
|
+
const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseAnchorPath, sliceLines, buildFileOutline, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection } = core
|
|
9
9
|
|
|
10
10
|
export function createDispatch(ctx, state, deps) {
|
|
11
11
|
const fs = ctx.fs
|
|
12
12
|
var rt = deps.rt, wt = deps.wt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
|
|
13
13
|
var rootForSession = deps.rootForSession, sessionCwd = deps.sessionCwd, withTimeout = deps.withTimeout, runsFor = deps.runsFor, feedbackOn = deps.feedbackOn
|
|
14
|
+
// 史诗拆分总开关读取器(epicSplit,缺省 true):与 feedbackOn 同源(session 缓存,rt() 同步)——
|
|
15
|
+
// Team 提示词组装是同步函数,只能读缓存,不能读盘。
|
|
16
|
+
var epicSplitOn = deps.epicSplitOn
|
|
14
17
|
var pushSysNote = deps.pushSysNote, maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
|
|
18
|
+
// 派发即回执:spawn 成功后入 45s 聚合队列的「🚀 已派发」区(老 host 未注入 → 静默跳过)
|
|
19
|
+
var notifyDispatched = deps.notifyDispatched
|
|
15
20
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
16
21
|
var dispatchedEver = state.dispatchedEver
|
|
17
22
|
var badModels = state.badModels
|
|
@@ -140,6 +145,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
140
145
|
|
|
141
146
|
async function spawnOneShot(sid, t, role) {
|
|
142
147
|
var subagents = ctx.subagents; if (!subagents) return null
|
|
148
|
+
// 兜底保留(真异常时有用):poolCycle 入口已有 root 存活早闸门,正常派发路径不会到达这里;
|
|
149
|
+
// 只有闸门外的直接调用(如 dispatch-task RPC 指定了无 root 的 sid)才会触发此报错。
|
|
143
150
|
var parent = rootForSession(sid); if (!parent) { console.error('[task-board] no root agent for session ' + sid + ', skip spawn'); return null }
|
|
144
151
|
var providerName = pickProvider(); if (!providerName) { console.error('[task-board] no subagent provider'); return null }
|
|
145
152
|
var modelOverride = ''
|
|
@@ -156,7 +163,16 @@ export function createDispatch(ctx, state, deps) {
|
|
|
156
163
|
}
|
|
157
164
|
// user prompt 只留一行指引,内容走上下文注入区块
|
|
158
165
|
var packNote = pack ? '本任务附带主窗口预研文件,已通过「上下文注入」区提供(含文件完整内容),直接基于其内容工作,不要重复读取这些文件。' : ''
|
|
159
|
-
|
|
166
|
+
// prompt 三态:worker / verifier / hook(hooks=agent run:pre 与 post 共用 buildHookPrompt,
|
|
167
|
+
// 由 phase 决定契约文案——二者都是挂在 epic 上的一次性真实 agent 运行)。
|
|
168
|
+
// hook 分支现读一次看板只为拿子任务清单;读失败退化为空清单(prompt 仍成立,绝不因此不 spawn)。
|
|
169
|
+
var promptText
|
|
170
|
+
if (role === 'hook-pre' || role === 'hook-post') {
|
|
171
|
+
var kids = []
|
|
172
|
+
try { var hsnap = await rt(sid); kids = gsb(t.id, (hsnap && hsnap.tasks) || []) } catch (_) {}
|
|
173
|
+
promptText = buildHookPrompt(t, role === 'hook-pre' ? 'pre' : 'post', kids)
|
|
174
|
+
} else promptText = role === 'worker' ? buildWorkerPrompt(t, packNote, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, packNote)
|
|
175
|
+
var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: promptText }], parent: parent, signal: makeSignal() }
|
|
160
176
|
if (modelOverride) {
|
|
161
177
|
// list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
|
|
162
178
|
// 但 AgentOptions 的 provider 和 model 是分开的——整串塞进 model 会报 UNKNOWN_MODEL
|
|
@@ -214,6 +230,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
214
230
|
var errText = err ? String(err) : (res && (res.diagnostic || res.stopReason) || '')
|
|
215
231
|
try {
|
|
216
232
|
if (rec.role === 'worker') await settleWorker(sid, rec, output, failed, errText)
|
|
233
|
+
else if (rec.role === 'hook-pre' || rec.role === 'hook-post') await settleHook(sid, rec, output, failed, errText)
|
|
217
234
|
else await settleVerifier(sid, rec, output, failed, errText)
|
|
218
235
|
} catch (e) { console.error('[task-board] settle ' + rec.role + ' failed (task ' + rec.taskId + '):', String(e)) }
|
|
219
236
|
// 历史会话留档:记录该次 run 的结局(完成/失败/硬超时),详情页可据此标注阶段状态
|
|
@@ -224,12 +241,35 @@ export function createDispatch(ctx, state, deps) {
|
|
|
224
241
|
await accumulateRunUsage(sid, rec)
|
|
225
242
|
}
|
|
226
243
|
|
|
227
|
-
//
|
|
244
|
+
// 本地日期 key(YYYY-MM-DD):byDay 日账的唯一口径。
|
|
245
|
+
// 必须用本地 getters 拼——toISOString() 是 UTC,晚上 8 点后的消耗会被记到次日,
|
|
246
|
+
// 「今日消耗」在东八区会从每天 08:00 起算,直接错位。
|
|
247
|
+
function localDayKey(d) {
|
|
248
|
+
var x = d || new Date()
|
|
249
|
+
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
250
|
+
return x.getFullYear() + '-' + p2(x.getMonth() + 1) + '-' + p2(x.getDate())
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)。
|
|
254
|
+
// 为什么单列:本板实测累计 total 里缓存读占 94%(9.7M/10.3M),大数字被缓存读撑高约 17 倍,
|
|
255
|
+
// 与「真实花掉多少」严重脱节——有效消耗才是可比的成本口径,缓存读单列展示。
|
|
256
|
+
function effectiveOf(u) {
|
|
257
|
+
if (!u) return 0
|
|
258
|
+
return (u.input || 0) + (u.output || 0) + (u.cacheWrite || 0)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 把一次 run 的 token 消耗累加到任务(t.usage):总量/输入/输出/缓存读写 + 按模型小计 + runs 计数 + 日账。
|
|
228
262
|
// 模型小计的 key:优先本次派发显式覆盖的模型(rec.model),否则用日志里记录的会话模型。
|
|
263
|
+
// 日账(byDay):本次 run 整笔记到「结算时刻的本地日」——一次 run 不跨日拆分
|
|
264
|
+
// (跨零点的长 run 全算在结算日),换取实现极简与仪表盘「今日 / 近 7 天」可算。
|
|
265
|
+
// 双指标形态:byDay[day] = { t: total, e: effective }(e 是有效消耗,不含缓存读)。
|
|
266
|
+
// 老数据(number 形态,本轮之前落的日账)只在聚合端兼容:读侧按 { t: n, e: null } 处理,
|
|
267
|
+
// e 不可知就置 null(宁可展示上标 ~ 近似,也不伪造一个「有效值」)。
|
|
229
268
|
async function accumulateRunUsage(sid, rec) {
|
|
230
269
|
var u = null
|
|
231
270
|
try { u = readRunUsage(String(rec.run.id)) } catch (_) { u = null }
|
|
232
271
|
if (!u || !u.total) return
|
|
272
|
+
var eff = effectiveOf(u)
|
|
233
273
|
try {
|
|
234
274
|
await mutateLocked(sid, function (d) {
|
|
235
275
|
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
@@ -245,6 +285,28 @@ export function createDispatch(ctx, state, deps) {
|
|
|
245
285
|
if (!t.usage.models) t.usage.models = {}
|
|
246
286
|
var mk = rec.model || u.model || '(未知模型)'
|
|
247
287
|
t.usage.models[mk] = (t.usage.models[mk] || 0) + u.total
|
|
288
|
+
// 日账:老任务没有 byDay 就地补(不改写老字段形态);键是本地日 YYYY-MM-DD。
|
|
289
|
+
if (!t.usage.byDay) t.usage.byDay = {}
|
|
290
|
+
// 脏值兜底:历史/半写数据里 byDay[dk] 可能是 number(老形态)或字符串,一律收敛成对象记账
|
|
291
|
+
var dk = localDayKey()
|
|
292
|
+
var cell = t.usage.byDay[dk]
|
|
293
|
+
if (!cell || typeof cell !== 'object') cell = { t: Number(cell) || 0, e: 0 }
|
|
294
|
+
cell.t = (Number(cell.t) || 0) + u.total
|
|
295
|
+
cell.e = (Number(cell.e) || 0) + eff
|
|
296
|
+
t.usage.byDay[dk] = cell
|
|
297
|
+
// ===== run 级留账:把本次 run 的用量原样写回它在 t.runs 里的条目 =====
|
|
298
|
+
// 作用:总量口径万一再要调整(换分母/排除某类 run/按天或按模型重建),不必回头猜——
|
|
299
|
+
// 每个 run 自带五分量,任意维度都能精确重算(byDay 只是它的一个投影)。
|
|
300
|
+
// 找不到条目就跳过:t.runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,
|
|
301
|
+
// 正常结算路径必存在;只有手写/裁剪过的历史任务会缺,缺了也不该阻断聚合累加。
|
|
302
|
+
if (Array.isArray(t.runs)) {
|
|
303
|
+
for (var ri = t.runs.length - 1; ri >= 0; ri--) {
|
|
304
|
+
if (String(t.runs[ri] && t.runs[ri].id) === String(rec.run.id)) {
|
|
305
|
+
t.runs[ri].usage = { input: u.input || 0, output: u.output || 0, cacheRead: u.cacheRead || 0, cacheWrite: u.cacheWrite || 0, total: u.total || 0 }
|
|
306
|
+
break
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
248
310
|
t.usage.updatedAt = new Date().toISOString()
|
|
249
311
|
return { ok: true }
|
|
250
312
|
})
|
|
@@ -252,7 +314,11 @@ export function createDispatch(ctx, state, deps) {
|
|
|
252
314
|
}
|
|
253
315
|
|
|
254
316
|
async function settleWorker(sid, rec, output, failed, errText) {
|
|
317
|
+
// 完成回执开关(设置区「通知」notifyDone):在同一次持锁回调里读看板文档(零额外读盘),
|
|
318
|
+
// 缺字段/回调未跑到时保持 true——宁可多报一条,也不因读配置失败漏报完成。
|
|
319
|
+
var doneOn = true
|
|
255
320
|
var result = await mutateLocked(sid, function (d) {
|
|
321
|
+
doneOn = cfg(d).notifyDone !== false
|
|
256
322
|
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
257
323
|
if (!t) return null
|
|
258
324
|
// 工具通道已处理(board_report 已推进到 verifying/resolved 或挂了 escalation)→ 只收尾
|
|
@@ -283,13 +349,17 @@ export function createDispatch(ctx, state, deps) {
|
|
|
283
349
|
// already=true:工具通道(board_report)已推进状态并已发回执/歧义通知,settle 只负责 dispose,不再重复通知
|
|
284
350
|
if (result.already) return
|
|
285
351
|
if (result.escalated) maybeNotify(sid, result.task)
|
|
286
|
-
|
|
287
|
-
if (result.
|
|
352
|
+
// 歧义通知(maybeNotify → notifyMainWindow)是裁决通道,不受回执开关影响:只闸下面两条完成/阻塞回执
|
|
353
|
+
if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
|
|
354
|
+
if (doneOn && result.blocked) notifyTaskDone(sid, result.task, 'blocked')
|
|
288
355
|
kickCycle(sid) // 结算后立刻补派
|
|
289
356
|
}
|
|
290
357
|
|
|
291
358
|
async function settleVerifier(sid, rec, output, failed, errText) {
|
|
359
|
+
// 完成回执开关同上:持锁回调内取 notifyDone
|
|
360
|
+
var doneOn = true
|
|
292
361
|
var result = await mutateLocked(sid, function (d) {
|
|
362
|
+
doneOn = cfg(d).notifyDone !== false
|
|
293
363
|
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
294
364
|
if (!t) return null
|
|
295
365
|
if (t.status !== 'verifying' || t.escalation) return { task: t, already: true } // 工具通道已处理
|
|
@@ -312,6 +382,9 @@ export function createDispatch(ctx, state, deps) {
|
|
|
312
382
|
verifyApply(d, t, String(rec.run.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
|
|
313
383
|
if (!approved) {
|
|
314
384
|
t.rejectCount = (t.rejectCount || 0) + 1
|
|
385
|
+
// 驳回包全量带回(task-muvg15p5):summary + checks(逐条核对证据)落 messages,
|
|
386
|
+
// 随 buildMessages 注入重派 Worker prompt——history 里只有 200 字截断,Worker 据此无法返工
|
|
387
|
+
pushRejection(t, vsecs.verifySummary, vsecs.checks, t.verification.at, String(rec.run.id))
|
|
315
388
|
// 学习飞轮 v1:Verifier 驳回 → 候选教训(场景/错误做法/来源),随 t.verification.at 判重
|
|
316
389
|
pushRejectLesson(d, t, vsecs.verifySummary || trimmed, t.verification.at)
|
|
317
390
|
if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') }
|
|
@@ -323,17 +396,55 @@ export function createDispatch(ctx, state, deps) {
|
|
|
323
396
|
// already=true:工具通道(board_verdict)已推进状态并已发回执,settle 只负责 dispose,不再重复通知
|
|
324
397
|
if (result.already) return
|
|
325
398
|
if (result.escalated) maybeNotify(sid, result.task)
|
|
326
|
-
if (result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
|
|
327
|
-
if (result.task && result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
|
|
399
|
+
if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
|
|
400
|
+
if (doneOn && result.task && result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
|
|
328
401
|
kickCycle(sid)
|
|
329
402
|
}
|
|
330
403
|
|
|
404
|
+
// ===== hook run 结算(hooks=agent run 接线②)=====
|
|
405
|
+
// 照 worker/verifier 的结算模式:mutateLocked 持锁改状态机(状态机纯函数在 core.applyHookSettle,
|
|
406
|
+
// 单测直接打它),锁外发通知 + kickCycle 补派。
|
|
407
|
+
// hook-pre 完成 → hooks.pre.state='done'(下轮起子任务正常放行,串行闸门打开)
|
|
408
|
+
// hook-post 完成 → hooks.post.state='done' + epic 转 verifying(收口完成,交人验收)
|
|
409
|
+
// 失败(异常/stopReason 异常)或文本 [ESCALATE] → 该点位 state='failed' + epic 转 blocked +
|
|
410
|
+
// escalation 挂卡(歧义上报「前置准备失败,重试/跳过/放弃」),不自动重试。
|
|
411
|
+
// 幂等:epic 已有未裁决 escalation 时只收口不覆盖(主窗口裁决前不刷屏)。
|
|
412
|
+
async function settleHook(sid, rec, output, failed, errText) {
|
|
413
|
+
var phase = rec.role === 'hook-pre' ? 'pre' : 'post'
|
|
414
|
+
var esc = /\[ESCALATE\]/i.test(output || '')
|
|
415
|
+
var runId = String(rec.run.id)
|
|
416
|
+
// 完成回执开关(notifyDone):hook 收口把 epic 推进到 blocked/verifying 同样算完成回执,一视同仁
|
|
417
|
+
var doneOn = true
|
|
418
|
+
var result = await mutateLocked(sid, function (d) {
|
|
419
|
+
doneOn = cfg(d).notifyDone !== false
|
|
420
|
+
var p = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
421
|
+
if (!p) return null
|
|
422
|
+
return applyHookSettle(d, rec.taskId, phase, !(failed || esc), output, runId, errText)
|
|
423
|
+
})
|
|
424
|
+
if (!result) return
|
|
425
|
+
if (result.already) return
|
|
426
|
+
if (result.blocked) { maybeNotify(sid, result.task); if (doneOn) notifyTaskDone(sid, result.task, 'blocked') }
|
|
427
|
+
if (doneOn && result.closed) notifyTaskDone(sid, result.task, 'resolved')
|
|
428
|
+
kickCycle(sid) // 结算后立刻补派(pre 完成 → 子任务开跑;post 完成 → epic 进验收)
|
|
429
|
+
}
|
|
430
|
+
|
|
331
431
|
// ===== 派发周期(15s 心跳 + 写入后 kickCycle 触发)=====
|
|
332
432
|
async function poolCycle(sid) {
|
|
433
|
+
// root 存活早闸门(根治 no-root 刷屏,task-muuf0o7a):无活 root 的会话板根本不进派发循环——
|
|
434
|
+
// 不读盘、不 claim、不回收、不写盘、零日志。幻影板(裸短 id 建的 tasks-<短id>.json,真实会话
|
|
435
|
+
// id 带 session- 前缀与后缀,rootForSession 永远匹配不到)或已关闭会话的残留板若进循环,
|
|
436
|
+
// 每 15s 心跳都会 pickDispatch→占位 claim→spawnOneShot 才发现无 root→console.error→占位
|
|
437
|
+
// 超时回收→下轮再来,永久 spam。死会话的 pending 卡等会话重开后自然恢复派发
|
|
438
|
+
// (这正是孤儿板接管语义),此处无需任何动作。
|
|
439
|
+
// 返回值说明:调用方(store.kickCycle / 15s 心跳)均忽略返回值,返回 undefined 签名兼容。
|
|
440
|
+
if (!rootForSession(sid)) return undefined
|
|
333
441
|
var info = []
|
|
334
442
|
var runs = runsFor(sid)
|
|
335
443
|
var snap = await rt(sid)
|
|
336
444
|
var activeW = 0, activeV = 0
|
|
445
|
+
// 角色口径:worker 计入 activeW(占 Worker 并发位);verifier 与 hook run(hook-pre/hook-post)
|
|
446
|
+
// 统一计入 activeV——hook run 不是 Worker,不该挤占 maxWorkers 并发位,但它确实是一条在跑的 run,
|
|
447
|
+
// 必须参与「空闲快进」判定,否则 hook 跑着时重复派发的闸门会失守。
|
|
337
448
|
Object.keys(runs).forEach(function (k) { if (runs[k].role === 'worker') activeW++; else activeV++ })
|
|
338
449
|
// 空闲快进:无活跃任务且无活跃 run → 不写盘直接返回(心跳每 15s 跑一次,不能每次都写文件)
|
|
339
450
|
var hasActive = snap.tasks.some(function (t) { return t.status === 'pending' || t.status === 'verifying' || t.status === 'in-progress' })
|
|
@@ -366,16 +477,43 @@ export function createDispatch(ctx, state, deps) {
|
|
|
366
477
|
var picked = pickDispatch(d, capW, Math.max(0, c.maxVerifiers - activeV), runs)
|
|
367
478
|
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) })
|
|
368
479
|
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) })
|
|
480
|
+
// ===== hooks=agent run:pre 闸门占用 + post 收口补 spawn(接线①③)=====
|
|
481
|
+
// pre:候选被 pickDispatch 的 preHookGate 拦下时,本轮不派子任务——这里改 spawn hook-pre run。
|
|
482
|
+
// 触发条件用状态机本身(state='idle' 即「已声明且从未跑过」,重启后从卡上原样恢复,不靠内存标记);
|
|
483
|
+
// state='running' 说明已有 hook run 在跑(幂等,同一 epic 同时最多一条),'done'/'failed' 都轮不到这里。
|
|
484
|
+
// post:子任务全部了结时 core.maybeAutoCloseParent 不直接转 verifying,只置 hooks.post.pending
|
|
485
|
+
// 标记;本轮在这里看到标记就 spawn hook-post run(无活跃子任务时也能被下一次心跳收走)。
|
|
486
|
+
d.tasks.forEach(function (t) {
|
|
487
|
+
if (hookOn(t, 'pre') && t.hooks.pre.state === 'idle' && !runs[t.id]) {
|
|
488
|
+
hookSetState(t, 'pre', 'running', 'system', '派发前置 hook run')
|
|
489
|
+
toSpawn.push({ role: 'hook-pre', t: t })
|
|
490
|
+
info.push('hook-pre ' + t.id)
|
|
491
|
+
}
|
|
492
|
+
if (t.status === 'in-progress' && hookOn(t, 'post') && t.hooks.post.pending && !t.verifierRun && !runs[t.id]) {
|
|
493
|
+
t.hooks.post.pending = false
|
|
494
|
+
t.hooks.post.state = 'running'
|
|
495
|
+
t.verifierRun = 'spawn-pending' // 复用 Verifier 幂等占用位:只在 spawn 成功后换成真实 run id
|
|
496
|
+
t.verifierRunAt = new Date().toISOString()
|
|
497
|
+
toSpawn.push({ role: 'hook-post', t: t })
|
|
498
|
+
info.push('hook-post ' + t.id)
|
|
499
|
+
}
|
|
500
|
+
})
|
|
369
501
|
// touches 文件级排他展示态:被拦候选写 t.waitingForTouches = [持有者任务id...],
|
|
370
502
|
// 未被拦/已派发/已落定的任务清除该字段(每心跳刷新的 UI 展示态,不参与任何派发逻辑,
|
|
371
503
|
// 但必须显式清——只在写入时报字段会留下"锁已释放仍显示 🔒 等待"的永久误导)。
|
|
504
|
+
// 持锁口径的唯一出处是 core.holdsFiles(in-progress + claimedBy / verifying / resolved 都持锁,
|
|
505
|
+
// 归档才真释放——反馈 n-muupqg81u575)。这里不做任何放锁动作:verifying/resolved 卡不放锁,
|
|
506
|
+
// 故 conflicts 里出现 resolved 卡 id 是预期行为(滞留原因对用户可见,就是主窗口还没归档);
|
|
507
|
+
// 归档动作(archive-task / task_archive)把卡改成 archived 后,下轮 tickInProgress 15s 轮
|
|
508
|
+
// 自然把等待卡放行,无需在此额外触发补派。
|
|
372
509
|
var waitMap = {}
|
|
373
510
|
picked.blockedTouches.forEach(function (b) { waitMap[b.id] = b.conflicts })
|
|
374
511
|
d.tasks.forEach(function (t) {
|
|
375
512
|
if (waitMap[t.id]) { t.waitingForTouches = waitMap[t.id]; info.push('wait-touches ' + t.id + '<-' + waitMap[t.id].join(',')) }
|
|
376
513
|
else if (t.waitingForTouches) delete t.waitingForTouches
|
|
377
514
|
})
|
|
378
|
-
// UI 池状态:来自活跃 run
|
|
515
|
+
// UI 池状态:来自活跃 run(一次性模型:没有成员名册,只有在跑的任务)。
|
|
516
|
+
// hook run 也归「verifiers」区展示(同一格里都是非 Worker 的一次性 run)。
|
|
379
517
|
d.poolStatus = { workers: [], verifiers: [] }
|
|
380
518
|
Object.keys(runs).forEach(function (k) { var rc = runs[k]; d.poolStatus[rc.role === 'worker' ? 'workers' : 'verifiers'].push({ id: k, num: '-', busy: true, taskId: rc.taskId, runId: String(rc.run.id), done: 0, queueLen: 0, suspect: false, model: rc.model || '' }) })
|
|
381
519
|
if (info.length > 0) { d.dispatchInfo = info.join('; '); d.dispatchInfoAt = new Date().toISOString() }
|
|
@@ -388,8 +526,12 @@ export function createDispatch(ctx, state, deps) {
|
|
|
388
526
|
var sp = toSpawn[k]
|
|
389
527
|
var rec = await spawnOneShot(sid, sp.t, sp.role)
|
|
390
528
|
if (rec) {
|
|
391
|
-
// claim 占位换成真实 run id;verifier run 单独记(claimedBy 保留 worker 的,供详情页跳转会话)
|
|
392
|
-
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t) { if (sp.role === 'worker' && t.claimedBy === 'spawn-pending') t.claimedBy = String(rec.run.id); if (sp.role === 'verifier' && t.verifierRun === 'spawn-pending') { t.verifierRun = String(rec.run.id); t.verifierRunAt = new Date().toISOString() } }; return t }, true)
|
|
529
|
+
// claim 占位换成真实 run id;verifier/hook-post run 单独记(claimedBy 保留 worker 的,供详情页跳转会话)
|
|
530
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t) { if (sp.role === 'worker' && t.claimedBy === 'spawn-pending') t.claimedBy = String(rec.run.id); if ((sp.role === 'verifier' || sp.role === 'hook-post') && t.verifierRun === 'spawn-pending') { t.verifierRun = String(rec.run.id); t.verifierRunAt = new Date().toISOString() }; if (sp.role === 'hook-pre' && t.hooks && t.hooks.pre) t.hooks.pre.runId = String(rec.run.id) }; return t }, true)
|
|
531
|
+
// 派发即回执:spawn 真成功后才入队(占位阶段失败不通知);经 deps 注入,未注入静默跳过(老 host 兼容)
|
|
532
|
+
// 回执开关(设置区「通知」):notifyDispatch=false → 派发回执整条跳过(spawn 照常,只闭嘴);
|
|
533
|
+
// 闸门读本轮 poolCycle 已取的 cfg 快照 c(无额外读盘),老看板缺字段 → cfg 归一为 true。
|
|
534
|
+
if (c.notifyDispatch !== false && typeof notifyDispatched === 'function') notifyDispatched(sid, sp.t, sp.role)
|
|
393
535
|
} else if (sp.role === 'worker') {
|
|
394
536
|
// spawn 失败 → 回 pending
|
|
395
537
|
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t && t.status === 'in-progress' && t.claimedBy === 'spawn-pending') { t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, 'in-progress', 'pending', 'system', 'spawn 失败,回收重新排队') }; return t }, true)
|
|
@@ -398,6 +540,14 @@ export function createDispatch(ctx, state, deps) {
|
|
|
398
540
|
// verifier spawn 失败 → 清占位,下轮 cycle 重试(占位不清会永远卡住派发)
|
|
399
541
|
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t && t.verifierRun === 'spawn-pending') { t.verifierRun = null; delete t.verifierRunAt }; return t }, true)
|
|
400
542
|
pushSysNote(sid, '任务「' + sp.t.title + '」Verifier 启动失败,下轮自动重试')
|
|
543
|
+
} else if (sp.role === 'hook-pre') {
|
|
544
|
+
// hook-pre spawn 失败 → 退回 idle + 重新置待跑标记,下轮自动重试(串行闸门保持关闭)
|
|
545
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t && t.hooks && t.hooks.pre && t.hooks.pre.state === 'running') { t.hooks.pre.state = 'idle'; t.hooks.pre.runId = null; t.hooks.pre.pending = true }; return t }, true)
|
|
546
|
+
pushSysNote(sid, '史诗「' + sp.t.title + '」前置 hook 启动失败,下轮自动重试')
|
|
547
|
+
} else if (sp.role === 'hook-post') {
|
|
548
|
+
// hook-post spawn 失败 → 清幂等占用 + 保留待跑标记,下轮自动重试
|
|
549
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t) { if (t.verifierRun === 'spawn-pending') { t.verifierRun = null; delete t.verifierRunAt } if (t.hooks && t.hooks.post && t.hooks.post.state !== 'done') t.hooks.post.pending = true }; return t }, true)
|
|
550
|
+
pushSysNote(sid, '史诗「' + sp.t.title + '」收口 hook 启动失败,下轮自动重试')
|
|
401
551
|
}
|
|
402
552
|
}
|
|
403
553
|
return result
|
|
@@ -424,7 +574,9 @@ export function createDispatch(ctx, state, deps) {
|
|
|
424
574
|
// 不能走 resolveRoot:该函数在每个 agent 每次 prompt 组装时同步执行,
|
|
425
575
|
// resolveRoot 全表扫 agent 注册表会把宿主事件循环卡死(曾导致全局界面卡顿、用户消息延迟渲染)。
|
|
426
576
|
if (!teamModeCache[String(agent.id)]) return ''
|
|
427
|
-
|
|
577
|
+
// 第 6 条(拆分条款)走 epicSplit 门禁(缺省 true = 逐字不变):关掉只是不再主动劝拆,
|
|
578
|
+
// 显式 parentId 建子卡 / 史诗自动收口 / hooks 状态机全部照常(机制不禁)。
|
|
579
|
+
return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles 把路径带上——两者都会通过「上下文注入」通道传给子代理(独立注入区块,不占对话流)。子代理是全新会话、无你的会话记忆,上下文不够它需要从零自行调研,效率大打折扣甚至跑偏方向——开发类任务(代码改动/修复/特性)务必带文件调研,实测可省 Worker 10~15 分钟自行 grep 定位;未带调研上下文的开发类任务返回会附 warning。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. Team 模式下 task_create 默认建为草稿(草稿不会被派发领取)。把所有任务的 dependsOn 依赖关系、contextNotes/contextFiles 都补完后,再逐个 task_update publish=true 统一发布。确实需要立即派发的单个任务才显式传 draft:false。' + splitRuleOf(epicSplitOn(String(agent.id))) + (feedbackOn(String(agent.id)) ? '\n' + LESSON_RECALL_HINT + '把检索到的相关历史教训写进任务的 contextNotes,让子代理少踩重复的坑。' : '')
|
|
428
580
|
},
|
|
429
581
|
})
|
|
430
582
|
ctx.effect(function () { return disposeSection })
|
package/lib/health.mjs
CHANGED
|
@@ -12,12 +12,33 @@ var TOUCH_HOT_MIN = 8 // 信号 a:单路径 touches 声明次数阈值
|
|
|
12
12
|
var TOUCH_HOT_RATIO = 0.4 // 信号 a:声明次数占窗口内「有 touches 任务」的比例阈值
|
|
13
13
|
var SERIAL_MIN_TOUCH = 5 // 信号 b:有 touches 任务的最小样本数(不足则不判定,防小样本噪声)
|
|
14
14
|
var SERIAL_RATIO = 2 // 信号 b:有 touches 任务滞留中位数 > 无 touches 任务的 N 倍
|
|
15
|
+
var SERIAL_MIN_WAIT_MIN = 5 // 信号 b:**绝对**阈值——有 touches 侧滞留中位数不足 5min 一律不判定
|
|
16
|
+
// (task-muupnnq5 实证:线上曾挂「0min vs 0min」告警——0.4min > 0.1min×2 满足
|
|
17
|
+
// 相对倍数但两者都不到半分钟,亚分钟差异毫无架构意义,是纯噪声)
|
|
15
18
|
var P90_LIMIT_MIN = 45 // 信号 c:resolved 任务执行时长(claimedAt→resolvedAt)p90 阈值(分钟)
|
|
16
19
|
var REJECT_HOT_MIN = 2 // 信号 d:单路径累计驳回次数阈值
|
|
17
20
|
var MAX_HINTS = 3 // 返回提示上限(warn 信号优先排列)
|
|
18
21
|
|
|
19
|
-
//
|
|
20
|
-
|
|
22
|
+
// 信号 a 分母防灌水(task-muupnnq5):这些路径被流程性挂在几乎每张开发卡上,不代表架构收敛点。
|
|
23
|
+
// 「组装产物 / 守门员路径」黑名单——过滤只作用于**本健康度模块内的路径聚合**(信号 a 热度分母、信号 d 归因),
|
|
24
|
+
// 不动 touches 本身语义:锁冲突、并行度判定、派发排队一概不受影响(core.mjs 的 matchOne 不读本表)。
|
|
25
|
+
// 副作用(刻意接受):挂在这些路径上的驳回也不再触发信号 d——对「产物/守门员」报警反而会掩盖真脆弱区。
|
|
26
|
+
// 加名单的唯一理由必须是「流程性挂载」,不是「看着不重要」。
|
|
27
|
+
var TOUCH_NOISE_PATHS = {
|
|
28
|
+
'lib/client.js': true, // 派生产物:由 lib/client/*.js 组装,改源码的卡几乎都会挂它(改它反而不对)
|
|
29
|
+
'test': true, // 守门员目录:单测随每张开发卡走
|
|
30
|
+
'README.md': true, // 双份 README 的根仓副本:随下次发版统一核对,不指代模块收敛点
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 是否为「机械随卡」噪声路径(normTouch 已归一:\ →/、去 ./、去尾 /)
|
|
34
|
+
function isNoiseTouchPath(p) {
|
|
35
|
+
if (TOUCH_NOISE_PATHS[p]) return true
|
|
36
|
+
return p.indexOf('test/') === 0 // test/ 整棵子树(含 test/x.test.mjs)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 毫秒 → 分钟(四舍五入取整,仅供文案展示);不足 1 分钟显示 <1min——
|
|
40
|
+
// 否则 0.4min 与 0.1min 都渲染成「0min」,告警看起来像幽灵(task-muupnnq5 线上实证)
|
|
41
|
+
function toMin(ms) { var m = Math.round(ms / 60000); return m < 1 ? '<1min' : m + 'min' }
|
|
21
42
|
|
|
22
43
|
// 中位数(输入任意序,内部复制排序;空数组返回 null)
|
|
23
44
|
function medianOf(nums) {
|
|
@@ -36,10 +57,19 @@ function percentileOf(nums, p) {
|
|
|
36
57
|
return v[i]
|
|
37
58
|
}
|
|
38
59
|
|
|
39
|
-
//
|
|
40
|
-
|
|
60
|
+
// 滞留代理:createdAt →(claimedAt 优先,否则 resolvedAt)。
|
|
61
|
+
// task-muupnnq5 口径补充:仍在排队(无 claimedAt 也无 resolvedAt)的 pending 卡用 **now - createdAt** 计入——
|
|
62
|
+
// 「还在排队等锁」同样是等待,旧口径直接返回 -1 把它们整批丢掉(右删失),会系统性低估带 touches 任务的滞留。
|
|
63
|
+
// 副作用(刻意接受):窗口里长期无人认领的僵尸 pending 卡滞留随时间单调增长,会持续计入有/无 touches 分组;
|
|
64
|
+
// 这是「锁 + 派发双重饥饿」的真实信号,不是噪声。无法计算返回 -1。
|
|
65
|
+
function waitMsOf(t, now) {
|
|
41
66
|
var end = t.claimedAt || t.resolvedAt
|
|
42
|
-
if (!end
|
|
67
|
+
if (!end) {
|
|
68
|
+
if (!t.createdAt) return -1
|
|
69
|
+
var pm = now - Date.parse(t.createdAt)
|
|
70
|
+
return (isNaN(pm) || pm < 0) ? -1 : pm
|
|
71
|
+
}
|
|
72
|
+
if (!t.createdAt) return -1
|
|
43
73
|
var ms = Date.parse(end) - Date.parse(t.createdAt)
|
|
44
74
|
return (isNaN(ms) || ms < 0) ? -1 : ms
|
|
45
75
|
}
|
|
@@ -52,14 +82,14 @@ function durationMsOf(t) {
|
|
|
52
82
|
return (isNaN(ms) || ms < 0) ? -1 : ms
|
|
53
83
|
}
|
|
54
84
|
|
|
55
|
-
// 任务 touches 归一化(与 core.mjs normTouch 同款口径)+
|
|
85
|
+
// 任务 touches 归一化(与 core.mjs normTouch 同款口径)+ 单任务内去重 + 机械随卡路径过滤;无 touches 返回 []
|
|
56
86
|
function normPathsOf(t) {
|
|
57
87
|
var raw = Array.isArray(t.touches) ? t.touches : []
|
|
58
88
|
var seen = {}, out = []
|
|
59
89
|
for (var i = 0; i < raw.length; i++) {
|
|
60
90
|
if (typeof raw[i] !== 'string') continue
|
|
61
91
|
var p = normTouch(raw[i])
|
|
62
|
-
if (p && !seen[p]) { seen[p] = true; out.push(p) }
|
|
92
|
+
if (p && !seen[p] && !isNoiseTouchPath(p)) { seen[p] = true; out.push(p) }
|
|
63
93
|
}
|
|
64
94
|
return out
|
|
65
95
|
}
|
|
@@ -89,6 +119,7 @@ export function computeHealthHints(tasks) {
|
|
|
89
119
|
var waitWithout = [] // 信号 b:无 touches 任务的滞留毫秒
|
|
90
120
|
var withTouchN = 0 // 窗口内有 touches 的任务数(信号 a 占比分母)
|
|
91
121
|
var durations = [] // 信号 c:resolved 任务执行时长毫秒(claimedAt→resolvedAt)
|
|
122
|
+
var now = Date.now() // 信号 b:排队中 pending 卡的右删失代理要用「现在」(一次取定,长循环内不漂移)
|
|
92
123
|
for (var i = 0; i < win.length; i++) {
|
|
93
124
|
var t = win[i] || {}
|
|
94
125
|
var paths = normPathsOf(t)
|
|
@@ -98,7 +129,7 @@ export function computeHealthHints(tasks) {
|
|
|
98
129
|
}
|
|
99
130
|
var rc = t.rejectCount || 0
|
|
100
131
|
if (rc > 0) { for (var k = 0; k < paths.length; k++) rejCounts[paths[k]] = (rejCounts[paths[k]] || 0) + rc }
|
|
101
|
-
var w = waitMsOf(t)
|
|
132
|
+
var w = waitMsOf(t, now)
|
|
102
133
|
if (w >= 0) { if (paths.length) waitWith.push(w); else waitWithout.push(w) }
|
|
103
134
|
var dur = durationMsOf(t)
|
|
104
135
|
if (dur >= 0) durations.push(dur)
|
|
@@ -116,16 +147,17 @@ export function computeHealthHints(tasks) {
|
|
|
116
147
|
hints.push({ level: 'warn', text: rej[0] + ' 相关任务被驳回 ' + rej[1] + ' 次——质量脆弱区,建议审查该模块' })
|
|
117
148
|
}
|
|
118
149
|
// --- 信号 b:串行代价代理(有 touches 任务滞留中位数显著更长 = 并行度受锁限制)---
|
|
150
|
+
// 双重闸门:相对倍数(>2×)之外还要过**绝对**阈值(≥5min),治「0min vs 0min」这类亚分钟噪声告警。
|
|
119
151
|
if (waitWith.length >= SERIAL_MIN_TOUCH && waitWithout.length > 0) {
|
|
120
152
|
var medW = medianOf(waitWith), medWo = medianOf(waitWithout)
|
|
121
|
-
if (medW > medWo * SERIAL_RATIO) {
|
|
122
|
-
hints.push({ level: 'info', text: '带 touches 任务平均等待明显更长(中位数 ' + toMin(medW) + '
|
|
153
|
+
if (medW >= SERIAL_MIN_WAIT_MIN * 60000 && medW > medWo * SERIAL_RATIO) {
|
|
154
|
+
hints.push({ level: 'info', text: '带 touches 任务平均等待明显更长(中位数 ' + toMin(medW) + ' vs ' + toMin(medWo) + '),并行度受锁限制' })
|
|
123
155
|
}
|
|
124
156
|
}
|
|
125
157
|
// --- 信号 c:执行时长 p90(resolved 任务执行时长尾部抬升 = 粒度/架构成本恶化)---
|
|
126
158
|
var p90 = percentileOf(durations, 0.9)
|
|
127
159
|
if (p90 !== null && p90 > P90_LIMIT_MIN * 60000) {
|
|
128
|
-
hints.push({ level: 'info', text: '任务执行时长 p90 已达 ' + toMin(p90) + '
|
|
160
|
+
hints.push({ level: 'info', text: '任务执行时长 p90 已达 ' + toMin(p90) + '——超长任务占比升高,考虑拆分粒度或检查架构热点' })
|
|
129
161
|
}
|
|
130
162
|
return hints.slice(0, MAX_HINTS)
|
|
131
163
|
}
|
package/lib/notify.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// dsh-agent-board — 通知层(lib/notify.mjs)
|
|
2
2
|
// makeMsg(纯函数,模块级 export,rpc.mjs 直接 import):插件来源消息构造。
|
|
3
3
|
// createNotify(ctx, state, deps):歧义上报 25s 去抖通知 / 回执批量聚合(45s 或满 5 条)+ 主窗口空闲门控 /
|
|
4
|
-
// 系统异常通知队列 /
|
|
4
|
+
// 系统异常通知队列 / 投递前按看板现状过滤过期项(含 dispatched 派发回执的离场过滤)。共享状态全部经 state 显式注入。
|
|
5
|
+
// 回执两类入口共用同一聚合队列:notifyTaskDone(完成/阻塞)+ notifyDispatched(派发即回执)。
|
|
6
|
+
// 回执开关(板级 notifyDispatch/notifyDone,设置区「通知」小节)不在本模块判定——闸门在调用方
|
|
7
|
+
// (dispatch.mjs 派发/结算处读 cfg 快照),这样歧义裁决通知(notifyMainWindow)天然不受开关影响。
|
|
5
8
|
|
|
6
9
|
// makeMsg 支持插件来源标记(参考 dsh-notes 派发模式):
|
|
7
10
|
// form 'recall' = 背景回执(召回上下文,非指令);'notice' = 需注意的通知(带一行 summary)
|
|
@@ -22,6 +25,9 @@ export function createNotify(ctx, state, deps) {
|
|
|
22
25
|
var sysNotesBuf = state.sysNotesBuf
|
|
23
26
|
|
|
24
27
|
// 歧义上报通知:任何模式都通知主窗口(escalation 需要人工裁决,不能静默吞掉)
|
|
28
|
+
// ⚠️ 本通道是「裁决通道」不是回执——设置区的回执开关(notifyDispatch/notifyDone)只闸下面两条
|
|
29
|
+
// 回执入队入口(notifyTaskDone/notifyDispatched 的调用方),这里绝不读开关:关掉回执提醒是嫌吵,
|
|
30
|
+
// 关掉歧义提醒会让任务永久卡在等人裁决(闸门放在调用方而非本函数,就是为了让这条通道零开关)。
|
|
25
31
|
// 25s 去抖投递:主窗口 turn 进行中时 followup 只在宿主侧排队,送达时任务常已被裁决/归档(过期回声);
|
|
26
32
|
// 排队无法撤回,插件侧唯一可行的方案就是延迟 + 投递前重查看板。
|
|
27
33
|
// escNotifyTimers 存每个任务最新一次调度:同一任务再次上报即顶替旧调度(旧回调身份不匹配 → 静默丢弃);
|
|
@@ -59,18 +65,16 @@ export function createNotify(ctx, state, deps) {
|
|
|
59
65
|
// 回执幂等去重表:key = 任务id + 类别 + 完成事件指纹(deliverable/verification/resolvedAt/末条history 时间戳)。
|
|
60
66
|
// 同一完成事件被任何路径(工具直报/run 结算/未来回归)重复通知时指纹一致 → 吞掉;
|
|
61
67
|
// 驳回后重做完成 → 时间戳全换新 → 指纹不同 → 正常回执。
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
var key = t.id + ':' + stamp
|
|
68
|
+
// 回执入队(共享内核,notifyTaskDone / notifyDispatched 两条入口共用同一 45s 聚合队列):
|
|
69
|
+
// 幂等去重表 key = 任务id + 类别指纹 + 事件时间戳(1h TTL 清超龄键);入队后满 5 条立即冲刷,
|
|
70
|
+
// 否则挂 45s 窗口(timer 不可用则同步冲刷)。
|
|
71
|
+
function pushReceipt(sid, key, item) {
|
|
67
72
|
if (receiptedKeys[key]) return
|
|
68
73
|
var rkeys = Object.keys(receiptedKeys)
|
|
69
74
|
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]] } }
|
|
70
75
|
receiptedKeys[key] = Date.now()
|
|
71
76
|
var buf = receiptBuf[sid] || (receiptBuf[sid] = { items: [], timer: null })
|
|
72
|
-
|
|
73
|
-
buf.items.push({ kind: kind, title: t.title, id: t.id, summary: (t.deliverable && t.deliverable.summary) || '', note: lastNote })
|
|
77
|
+
buf.items.push(item)
|
|
74
78
|
if (buf.items.length >= 5) { flushReceipts(sid); return }
|
|
75
79
|
if (!buf.timer) {
|
|
76
80
|
var tm = ctx.timer
|
|
@@ -78,6 +82,23 @@ export function createNotify(ctx, state, deps) {
|
|
|
78
82
|
else flushReceipts(sid)
|
|
79
83
|
}
|
|
80
84
|
}
|
|
85
|
+
function notifyTaskDone(sid, t, kind) {
|
|
86
|
+
if (!t || !isDispatched(sid, t.claimedBy)) return
|
|
87
|
+
var lastHist = (t.history && t.history.length) ? String(t.history[t.history.length - 1].timestamp || '') : ''
|
|
88
|
+
var stamp = [kind, (t.deliverable && t.deliverable.at) || '', (t.verification && t.verification.at) || '', t.resolvedAt || '', lastHist].join('|')
|
|
89
|
+
var lastNote = (t.history && t.history.length) ? String(t.history[t.history.length - 1].note || '') : ''
|
|
90
|
+
pushReceipt(sid, t.id + ':' + stamp, { kind: kind, title: t.title, id: t.id, summary: (t.deliverable && t.deliverable.summary) || '', note: lastNote })
|
|
91
|
+
}
|
|
92
|
+
// 派发即回执:任务被 Worker/Verifier 领走(spawn 成功)时入同一聚合队列,flush 出「🚀 已派发」区。
|
|
93
|
+
// 为什么与完成回执同队列:派发与完成常在同一 45s 窗口内(10 秒探针卡),分两条消息会刷屏;
|
|
94
|
+
// 同一条摘要里「已派发 → 完成/阻塞」相邻呈现,人一眼看清生命周期。role = worker/verifier。
|
|
95
|
+
// 判定用 claimedBy 真值(派发回执不依赖 dispatchedEver 的 run 记账——那条判定是给完成回执区分
|
|
96
|
+
// 「派发执行 vs 主窗口手动」用的;派发回执本身只可能由派发路径调用,天然是派发任务)。
|
|
97
|
+
function notifyDispatched(sid, t, role) {
|
|
98
|
+
if (!t || !t.id || !t.claimedBy) return
|
|
99
|
+
var stamp = 'dispatched|' + String(role || 'worker') + '|' + (t.claimedAt || t.verifierRunAt || '')
|
|
100
|
+
pushReceipt(sid, t.id + ':' + stamp, { kind: 'dispatched', role: String(role || 'worker'), title: t.title, id: t.id, summary: '', note: '' })
|
|
101
|
+
}
|
|
81
102
|
// 系统级异常通知队列(易失,随回执冲刷):模型熔断/spawn 失败/孤儿回收/看门狗标记
|
|
82
103
|
// taskId 可选:告警类通知(软超时提醒等)语义只对「任务仍在执行中」成立,
|
|
83
104
|
// 带上任务 id 后 flush 投递前可重读看板校验,任务已落定的过期告警直接丢弃
|
|
@@ -97,9 +118,13 @@ export function createNotify(ctx, state, deps) {
|
|
|
97
118
|
function noteOf(status) { return status === 'verifying' ? '验证中' : '进行中' }
|
|
98
119
|
// 组装投递文本(入参已是过滤后的存活项,避免用已丢弃项的计数)
|
|
99
120
|
function composeText(items, notes) {
|
|
100
|
-
var done = [], blocked = []
|
|
101
|
-
for (var i = 0; i < items.length; i++) { (items[i].kind === 'resolved' ? done : blocked).push(items[i]) }
|
|
102
|
-
var lines = [
|
|
121
|
+
var done = [], blocked = [], dispatched = []
|
|
122
|
+
for (var i = 0; i < items.length; i++) { (items[i].kind === 'resolved' ? done : (items[i].kind === 'dispatched' ? dispatched : blocked)).push(items[i]) }
|
|
123
|
+
var lines = [items.length ? '📋 [任务看板] 回执摘要(' + items.length + ' 条)' : '📋 [任务看板] 系统通知', '']
|
|
124
|
+
if (dispatched.length) {
|
|
125
|
+
lines.push('🚀 已派发 ' + dispatched.length + ' 个:')
|
|
126
|
+
for (var d = 0; d < dispatched.length && d < 8; d++) lines.push(' · ' + dispatched[d].title + ' (' + dispatched[d].id + ') — ' + (dispatched[d].role === 'verifier' ? 'Verifier 验收中' : 'Worker 执行中'))
|
|
127
|
+
}
|
|
103
128
|
if (done.length) {
|
|
104
129
|
lines.push('✅ 完成 ' + done.length + ' 个:')
|
|
105
130
|
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) : ''))
|
|
@@ -135,11 +160,16 @@ export function createNotify(ctx, state, deps) {
|
|
|
135
160
|
}
|
|
136
161
|
// b. 回执项:任务已 archived(人已手动归档 = 已知悉)→ 丢弃;
|
|
137
162
|
// resolved/blocked 保留(回执是主通道,任务查不到也保留,不能因读盘失败丢回执)
|
|
163
|
+
// dispatched(派发即回执):入队到 flush 之间任务可能已离场(10 秒探针卡快速完成、
|
|
164
|
+
// 被人取消/归档)——任务已不在 in-progress 说明「已派发」这条时效性信息已过期,
|
|
165
|
+
// 丢弃以免与同窗口的完成回执重复刷屏(完成回执本身就是主通道,信息不丢)。
|
|
166
|
+
// rt 读盘失败(tt 为 null)时保留,不因瞬时读盘错误吞掉派发回执。
|
|
138
167
|
var keptItems = []
|
|
139
168
|
for (var j = 0; j < items.length; j++) {
|
|
140
169
|
var it = items[j]
|
|
141
170
|
var tt = findTask(it.id)
|
|
142
171
|
if (tt && tt.status === 'archived') continue
|
|
172
|
+
if (it.kind === 'dispatched' && tt && tt.status !== 'in-progress') continue
|
|
143
173
|
keptItems.push(it)
|
|
144
174
|
}
|
|
145
175
|
// c. 过滤后全空 → 不再打扰主窗口
|
|
@@ -153,5 +183,5 @@ export function createNotify(ctx, state, deps) {
|
|
|
153
183
|
} else deliver().catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
|
|
154
184
|
}
|
|
155
185
|
|
|
156
|
-
return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, pushSysNote: pushSysNote }
|
|
186
|
+
return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote }
|
|
157
187
|
}
|
package/lib/policy.mjs
CHANGED
|
@@ -10,10 +10,23 @@ const { cfg, lessonText, pushLesson } = core
|
|
|
10
10
|
// 1) task_create 工具描述里的粒度契约 TASK_SIZE_CONTRACT;
|
|
11
11
|
// 2) Team 模式系统提示词第 6 条 TEAM_SPLIT_RULE(epic 父卡 + 子任务 + checkParentAuto 收尾);
|
|
12
12
|
// 3) create-task RPC / task_create 工具返回体命中时附加 suggestSplit 一行建议(不落盘、不改状态)。
|
|
13
|
+
// 板级总开关 epicSplit(缺省 true,UI 在入池配置弹层「功能」小节):**只关引导,不禁机制**。
|
|
14
|
+
// false 时的两个生效点=上面第 2、3 条(本文件 splitRuleOf / withSplitHint 统一收口):
|
|
15
|
+
// · splitRuleOf(false) → '':Team 提示词第 6 条整条不注入;
|
|
16
|
+
// · withSplitHint(out, t, false) → 原样返回:不再附 suggestSplit 软提示。
|
|
17
|
+
// 机制面一律不看这个开关:显式传 parentId 建子卡、检查/收口父子流转、hooks 状态机照常工作——
|
|
18
|
+
// 关的是「主动劝你拆」,不是「不许你拆」。
|
|
19
|
+
// 第 1 条(TASK_SIZE_CONTRACT)留在 task_create 的**静态**工具描述里:工具定义会快照进 request header
|
|
20
|
+
// (dsh-session 校验 description 必须是 string),没有按板动态能力,故不在开关范围内。
|
|
13
21
|
export var TASK_SIZE_CONTRACT = '建议粒度:单任务 10~30 分钟可独立完成。超出此范围的大任务请先拆分——建一张 epic 卡(parentId 体系)再挂子任务,别整坨塞进来。'
|
|
14
22
|
export var SUGGEST_SPLIT_TEXT = '任务看起来偏大(建议单任务 10~30 分钟):考虑拆分子任务(parentId)或收窄边界'
|
|
15
23
|
// Team 模式提示词第 6 条:大任务的 epic 拆分流程(父卡 pipeline=direct 不派发,子任务全 resolved 后父卡由 checkParentAuto 自动转 verifying)
|
|
16
24
|
export var TEAM_SPLIT_RULE = '6. 大任务必须拆分:预计超过 30 分钟的任务,先建一张 epic 父卡(pipeline 传 direct,不派发),再拆成若干 10~30 分钟的子任务(task_create 传 parentId=父卡 id,有先后顺序的用 dependsOn 串联)。子任务全部完成后父卡会自动标记完成(checkParentAuto)。'
|
|
25
|
+
// Team 提示词第 6 条的注入出口(含前导换行,调用方直接字符串相加):
|
|
26
|
+
// epicSplit 缺省/true → '\n' + TEAM_SPLIT_RULE(与开关落地前逐字相同,零变化);
|
|
27
|
+
// epicSplit === false → ''(条款整条不出现,编号 1~5 连续不受影响)。
|
|
28
|
+
// 纯函数:只看传入布尔,不读配置、不碰 IO——dispatch 侧从缓存取 cfg(d).epicSplit 后传进来。
|
|
29
|
+
export function splitRuleOf(epicSplit) { return epicSplit === false ? '' : '\n' + TEAM_SPLIT_RULE }
|
|
17
30
|
// 史诗特征词:命中即视为"整坨塞进来"的典型信号(与 TASK_SIZE_CONTRACT 配套;无 /g,可安全复用)
|
|
18
31
|
var EPIC_WORDS = /全量|整体|系统级|全面|重构|所有模块|整个/
|
|
19
32
|
// description 长度阈值(字符):超过它说明描述密度远超"10~30 分钟单任务"应有体量
|
|
@@ -27,7 +40,10 @@ export function suggestSplitOf(t) {
|
|
|
27
40
|
return ''
|
|
28
41
|
}
|
|
29
42
|
// 统一出口:只在命中时附加字段,老调用方拿到的返回体形态完全不变(多一个可选字段而已)
|
|
30
|
-
|
|
43
|
+
// 第三参 epicSplit(缺省 undefined = 开):显式 false 时直接原样返回——总开关关掉后不再主动劝拆,
|
|
44
|
+
// 返回体与「未命中」逐字同形(老调用方无感)。
|
|
45
|
+
export function withSplitHint(out, t, epicSplit) {
|
|
46
|
+
if (epicSplit === false) return out
|
|
31
47
|
var hint = suggestSplitOf(t)
|
|
32
48
|
if (hint && out && typeof out === 'object') out.suggestSplit = hint
|
|
33
49
|
return out
|