dsh-agent-board 1.7.2 → 1.7.3
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 +27 -10
- package/index.mjs +10 -4
- package/lib/client/dashboard.js +58 -17
- package/lib/client/kernel.js +44 -6
- package/lib/client/task-detail.js +7 -3
- package/lib/client.js +109 -26
- package/lib/core.mjs +61 -29
- package/lib/dispatch.mjs +920 -226
- package/lib/rpc.mjs +113 -17
- package/lib/store.mjs +26 -1
- package/lib/usage.mjs +160 -20
- package/package.json +1 -1
- package/test/core.test.mjs +1980 -104
package/lib/rpc.mjs
CHANGED
|
@@ -42,6 +42,16 @@ export function createRpc(ctx, state, deps) {
|
|
|
42
42
|
var rt = deps.rt, mutateLocked = deps.mutateLocked
|
|
43
43
|
var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
|
|
44
44
|
var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
|
|
45
|
+
// 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
|
|
46
|
+
// Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
|
|
47
|
+
// 行为与本次改造前逐字一致(那时本来就没人做这件事)。
|
|
48
|
+
var reportRunSettled = (typeof deps.reportRunSettled === 'function') ? deps.reportRunSettled : null
|
|
49
|
+
// 上报即收尾的统一调用点:失败只记日志、绝不影响工具返回值(收尾是附加动作,工具通道的结果已落定)。
|
|
50
|
+
function settleReportedRun(taskId) {
|
|
51
|
+
if (!reportRunSettled) return Promise.resolve(false)
|
|
52
|
+
return Promise.resolve(reportRunSettled(toolSessionId(), taskId, { outcome: 'completed' }))
|
|
53
|
+
.catch(function (e) { console.error('[task-board] 上报通道 run 收尾失败 (task ' + taskId + '):', String(e)); return false })
|
|
54
|
+
}
|
|
45
55
|
var pushSysNote = deps.pushSysNote, sessionCwd = deps.sessionCwd // 调研门禁③:epic 发布预检的投递通道与路径解析根
|
|
46
56
|
// 僵尸 epic 出清(反馈 n-mutma3mmwceq):archive 门禁的活性判定由 index.mjs 接线注入
|
|
47
57
|
// (runsFor(sid)[taskId] 存在且 !settled 即活跃)。未注入时按「有活跃 run」兜底——
|
|
@@ -68,8 +78,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
68
78
|
function handle(method, fn) { handlers[method] = fn }
|
|
69
79
|
|
|
70
80
|
// ===== 调研门禁③配套:epic 发布预检 =====
|
|
71
|
-
//
|
|
81
|
+
// 路径存在性判定:相对路径按「该会话工作区」解析(不靠进程 cwd);
|
|
72
82
|
// existsSync 同步轻量,只在 publish 时跑一遍(每子任务 ≤20 条路径,不读全文)。
|
|
83
|
+
// 注意:这是**唯一**还按会话工作区解析路径的地方(派发侧预研清单已不读盘,见 dispatch.readContextPack)。
|
|
73
84
|
function existsInSession(sid) {
|
|
74
85
|
var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
|
|
75
86
|
return function (p) { try { return fsNode.existsSync(path.isAbsolute(p) ? p : (cwd ? path.join(cwd, p) : p)) } catch (_) { return false } }
|
|
@@ -100,13 +111,13 @@ export function createRpc(ctx, state, deps) {
|
|
|
100
111
|
// ===== Tools =====
|
|
101
112
|
ctx.tools.register(defineTool({ name: 'task_list', description: '列出当前会话任务。', parameters: { type: 'object', properties: { status: { type: 'string', enum: ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'cancelled'] }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tag: { type: 'string' }, parentId: { type: 'string' }, includeArchived: { type: 'boolean' }, limit: { type: 'number' } }, required: [] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var d = await rt(sid); var a = d.tasks; var ts = a; if (!args.includeArchived) ts = ts.filter(function (x) { return x.status !== 'archived' }); if (args.status) ts = ts.filter(function (x) { return x.status === args.status }); if (args.priority) ts = ts.filter(function (x) { return x.priority === args.priority }); if (args.tag) ts = ts.filter(function (x) { return (x.tags || []).indexOf(args.tag) >= 0 }); if (args.parentId === 'null') ts = ts.filter(function (x) { return !isb(x) }); else if (args.parentId) ts = ts.filter(function (x) { return x.parentId === args.parentId }); var po = PRIO_RANK; ts.sort(function (a, b) { var dd = (po[b.priority] || 0) - (po[a.priority] || 0); return dd !== 0 ? dd : (a.createdAt || '').localeCompare(b.createdAt || '') }); var lim = Math.min(args.limit || 20, 100); var res = ts.slice(0, lim).map(function (x) { var e = Object.assign({}, x); if (isb(x)) { var p = gpt(x, a); if (p) e.parentSummary = { id: p.id, title: p.title, status: p.status } }; var ch = gsb(x.id, a); if (ch.length) { e.subtaskCount = ch.length; e.subtaskResolved = ch.filter(function (y) { return y.status === 'resolved' }).length }; return e }); var out = { tasks: res, total: ts.length, session: sid, actor: getActorId(), boardMode: d.boardMode || 'auto', teamMode: !!d.teamMode, poolStatus: d.poolStatus }; if (d.teamMode) out.teamHint = 'Team 模式已开启:实质性改动请优先 task_create 提交看板由池执行;调研/读取/讨论可直接进行;Worker 歧义会上报等你裁决。'; return out } }))
|
|
102
113
|
ctx.tools.register(defineTool({ name: 'task_context', description: '获取任务完整上下文。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, expandFiles: { type: 'boolean' }, includeParent: { type: 'boolean' }, includeSubtasks: { type: 'boolean' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var d = await rt(sid); var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found: ' + args.taskId }; return { ok: true, context: { task: t, inheritedContext: t.context || {} } } } }))
|
|
103
|
-
ctx.tools.register(defineTool({ name: 'task_preview_context', description: '派发前上下文预览:预演 task_create 的 contextFiles/contextNotes
|
|
114
|
+
ctx.tools.register(defineTool({ name: 'task_preview_context', description: '派发前上下文预览:预演 task_create 的 contextFiles/contextNotes 将注入给子代理的瘦身清单形态(调研笔记全文 + 文件清单行「路径:L行号 — 一句用途」;文件内容本体不进 prompt,由子代理按需 read 自取),用于确认材料是否足够。不创建任务。返回 ok=false/empty=true 说明无可注入内容。', parameters: { type: 'object', properties: { contextFiles: { type: 'array', items: { type: 'string' }, description: '预演的文件路径列表;支持「路径:L1-L2」锚点与可选「 — 一句用途」写法' }, contextNotes: { type: 'string', description: '预演的调研笔记' } }, required: [] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; toolSessionId(); /* 副作用:注册已知会话(心跳集合);清单组装不依赖会话 id */ var files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []; var notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''; if (!files.length && !notes.trim()) return { ok: false, error: 'contextFiles/contextNotes 至少提供一个' }; try { var pack = readContextPack({ context: { files: files, notes: notes } }); return { ok: true, empty: !pack, pack: pack } } catch (e) { return { ok: false, error: String(e).slice(0, 200) } } } }))
|
|
104
115
|
ctx.tools.register(defineTool({ name: 'task_claim', description: '领取待办任务→in-progress。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, reason: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, args.reason || 'claimed'); return { ok: true, task: t, context: { task: t, inheritedContext: t.context || {} } } }) } }))
|
|
105
116
|
ctx.tools.register(defineTool({ name: 'task_resolve', description: '提交验证(verifying)或阻塞(blocked)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, status: { type: 'string', enum: ['verifying', 'blocked'] }, resolution: { type: 'string' } }, required: ['taskId', 'status'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; if (t.claimedBy !== actor) return { ok: false, error: 'not claimed by you' }; if (args.status === 'verifying' && !args.resolution) return { ok: false, error: 'resolution required' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) } }))
|
|
106
117
|
ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; var __vr = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); __vr.hint = REJECT_REDISPATCH_HINT } return __vr }) } }))
|
|
107
118
|
ctx.tools.register(defineTool({ name: 'task_archive', description: '归档已解决/已取消任务(无活跃 run 的 in-progress 僵尸卡也可归档,用于 parentKick 僵尸态出清)。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __ae = archiveErr(sid, t); if (__ae) return { ok: false, error: __ae }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'archived'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) } }))
|
|
108
|
-
ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '
|
|
109
|
-
ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles
|
|
119
|
+
ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时以文件清单(路径:L行号 — 一句用途)随首条 prompt 注入,内容由子代理按需 read 自取' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; delete t.retryCount; delete t.stuckSince; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
|
|
120
|
+
ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时以「文件清单」(每行「路径:L起-L止 — 一句用途」)随首条 prompt 一次性注入,只给路径与行号、不含内容本体(子代理用 read 工具按行号范围按需自取:执行时盘面更新鲜,也免了大文件正文撑爆 prompt 与随快照每轮重发),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,全文随首条 prompt 一次性注入(≤8000 字符)。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 contextFiles、结论思路放进 contextNotes——实测可让 Worker 省去 10~15 分钟从零 grep 定位的时间,且方向感天壤之别(无调研的 Worker 只能靠标题猜需求、极易跑偏)。调研门禁:pipeline=full/work 且 touches 非空而未带调研上下文时,返回将附 warning 提示。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务持有文件锁(in-progress/verifying 持有,状态流转到 resolved/cancelled/archived 即释放——锁只护正在写的阶段,验收后-提交前的窗口期由主窗口提交纪律 + 史诗 post-hook 承接)。' + TASK_SIZE_CONTRACT, parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径;可写「路径:L1-L2 — 一句用途」),派发时以文件清单随首条 prompt 注入(只给路径行号,内容由子代理按需 read 自取)' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),全文随首条 prompt 一次性注入' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (args.hooks !== undefined) { var __hn = normalizeHooks(args.hooks); if (__hn.error) return { ok: false, error: __hn.error }; if (__hn.hooks && Object.keys(__hn.hooks).length) t.hooks = __hn.hooks } if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); /* 调研门禁 warning 族(与 create-task RPC 同口径,core 纯函数):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建 */ var __out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var __warns = createTaskWarnings(t); attachContextSuggestions(__out, __warns, t.touches, existsInSession(sid)); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
|
|
110
121
|
|
|
111
122
|
// ===== RPC =====
|
|
112
123
|
// get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
|
|
@@ -124,13 +135,19 @@ export function createRpc(ctx, state, deps) {
|
|
|
124
135
|
// 这里给出确定布尔值,UI 勾选态不依赖客户端各自的兜底写法。
|
|
125
136
|
d.notifyDispatch = cfg(d).notifyDispatch
|
|
126
137
|
d.notifyDone = cfg(d).notifyDone
|
|
138
|
+
// Worker 可续跑开关(workerContinuable,缺省 true):与上面两键同款——读路径用 cfg 归一后的值,
|
|
139
|
+
// 老看板缺字段也返回 true(新行为即默认),客户端不需要自己猜缺省。消费点只在派发引擎。
|
|
140
|
+
d.workerContinuable = cfg(d).workerContinuable
|
|
127
141
|
// 史诗拆分总开关(设置区「功能」小节)同法显式透出确定布尔值:老 host 不返回时客户端按「缺字段=开」兜底,
|
|
128
142
|
// UI 勾选态不依赖各自兜底写法。
|
|
129
143
|
d.epicSplit = cfg(d).epicSplit
|
|
130
144
|
// 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
|
|
131
145
|
d.workMode = deriveWorkMode(d)
|
|
132
146
|
// ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
|
|
133
|
-
|
|
147
|
+
// 统计范围(args.range 可选 { from, to } 本地日):仪表盘的「统计范围」筛选对 Token 区的
|
|
148
|
+
// 模型分布 / Top8 / 累计生效——run 级数据只在 host,所以过滤必须在 host 做(客户端只拿聚合,没法自己裁)。
|
|
149
|
+
// 缺省/空范围 = 现状逐字不变(aggregateUsageSummary 内部判定,空串不算范围)。
|
|
150
|
+
d.usageSummary = aggregateUsageSummary(d.tasks, args && args.range)
|
|
134
151
|
// 架构自省 L1:healthHints 现算(纯函数零存储零 IO,近 50 卡窗口),客户端「架构健康」区超阈值才显示
|
|
135
152
|
d.healthHints = computeHealthHints(d.tasks)
|
|
136
153
|
// 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, settled, resolved, active, activeTitle } },
|
|
@@ -141,21 +158,24 @@ export function createRpc(ctx, state, deps) {
|
|
|
141
158
|
// 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify——传输仍全量,省的是渲染
|
|
142
159
|
d.tasksHash = tasksHash(d.tasks)
|
|
143
160
|
return d })
|
|
144
|
-
// 派发前上下文预览:主 agent 用它确认"
|
|
161
|
+
// 派发前上下文预览:主 agent 用它确认"我将注入给子代理的瘦身清单"是否足够(不发任务、不落盘)
|
|
145
162
|
handle('preview-context', async function (args) {
|
|
163
|
+
rpcSessionId(args) // 副作用保留:注册已知会话(15s 心跳轮询集合);清单组装本身已不依赖会话工作区
|
|
146
164
|
var files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []
|
|
147
165
|
var notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''
|
|
148
166
|
if (!files.length && !notes.trim()) return { ok: false, error: 'contextFiles/contextNotes 至少提供一个' }
|
|
149
167
|
var pack = ''
|
|
150
|
-
try { pack =
|
|
168
|
+
try { pack = readContextPack({ context: { files: files, notes: notes } }) } catch (e) { return { ok: false, error: '清单组装失败: ' + String(e).slice(0, 120) } }
|
|
151
169
|
return { ok: true, filesCount: files.length, notesLen: notes.length, pack: pack, empty: !pack }
|
|
152
170
|
})
|
|
153
171
|
// 活动心跳:读子代理会话日志的最后一帧,提取最近的动作摘要(卡片/详情页展示"现在跑到哪了")
|
|
154
172
|
handle('agent-activity', async function (args) {
|
|
155
173
|
var sid = rpcSessionId(args)
|
|
156
174
|
var rec = runsFor(sid)[args.taskId]
|
|
157
|
-
if (!rec
|
|
158
|
-
|
|
175
|
+
if (!rec) return { ok: true, activity: null, reason: 'no active run' }
|
|
176
|
+
// 会话 id 统一取 rec.id:continuable Worker 的 rec 没有 run(rec.run=null,只有 childId),
|
|
177
|
+
// 一次性 run 的 rec.id === rec.run.id——两条路径在这里同构。
|
|
178
|
+
var child = String(rec.id)
|
|
159
179
|
try {
|
|
160
180
|
// 子会话日志定位:直接扫描 ~/.dsh/sessions/*/<child>/session.vN.jsonl.zstd
|
|
161
181
|
// (不再从看板路径反推 workspace——那依赖进程 cwd 凑巧等于工作区,曾是隐性 bug)
|
|
@@ -320,12 +340,68 @@ export function createRpc(ctx, state, deps) {
|
|
|
320
340
|
// 通道选择(v1.2.4):steer 优先——下一个 step 边界即消费;followup 要等整个 turn 结束,
|
|
321
341
|
// 长 turn 下干预形同失联(实测:Worker 单 turn 跑 10+ 分钟,「口径重写」类干预到位时活已按旧口径干完)。
|
|
322
342
|
// steer 不可用(老宿主无此方法)或抛错时回退 followup。
|
|
343
|
+
//
|
|
344
|
+
// ===== continuable 通道补齐(task-muwox2ii,发版门禁 steer 套件 4/8 红的根因)=====
|
|
345
|
+
// 病根:投递条件写的是 rec.run.localAgent——那是**一次性 run 的句柄**;continuable rec 没有 run
|
|
346
|
+
//(持久子会话,卡1 起改由宿主续跑管理器持有,见 spawnOneShot 的 rec 形态),于是整段投递被跳过:
|
|
347
|
+
// 干预没进任何会话、delivered 恒 false、channel 恒 '',Worker 全程收不到指令(steer e2e 的
|
|
348
|
+
// 「上报内容含干预标记」必红),而详情页仍记着「已干预」——人以为送达了。
|
|
349
|
+
// 修法:continuable rec 改走与卡2 续跑同一族通道,三态降级(能实时送达就绝不留成「事后注入」):
|
|
350
|
+
// ① 宿主 host-protocol 投递:Symbol.for('dsh.subagent.deliverPrompt') 是宿主为「适配器保留自有
|
|
351
|
+
// source 描述」留的符号键入口(dsh-subagent 里 Symbol.for 注册、实例上挂同名方法),
|
|
352
|
+
// delivery='steer' → 就近 step 边界消费;走它,子会话日志里记的仍是
|
|
353
|
+
// source.kind='plugin:dsh-agent-board' 的看板消息——与一次性路径 agent.steer(插件消息) 的存档
|
|
354
|
+
// 形态一致(e2e 的「干预被消费」断言口径因此不用改);
|
|
355
|
+
// ② 该符号不可用/抛错(老宿主、跨进程代理面)→ 回退公开 sendMessage(卡2 续跑同通道;宿主内部
|
|
356
|
+
// 也是按 delivery='steer' 投递,只是 source 会被记成 agent-message);
|
|
357
|
+
// ③ 仍然送不到(无 childId / root 不在线 / 会话已死)→ 回退既有记录注入(现状行为:干预文本进
|
|
358
|
+
// t.messages,下次派发随 prompt 注入),并在 history 里写明「未实时送达」,不让它冒充已送达。
|
|
359
|
+
// 一次性 rec 走的仍是原分支:agent.steer → agent.followup 逐字不变。
|
|
360
|
+
var DELIVER_PROMPT = (function () { try { return Symbol.for('dsh.subagent.deliverPrompt') } catch (_) { return null } })()
|
|
361
|
+
// 投递用的取消信号(与 dispatch.mjs 的 makeSignal 同形):只覆盖「入队前」的取消,构造不出来时退化为哑信号。
|
|
362
|
+
function makeSignal() { try { return new AbortController().signal } catch (_) { return { aborted: false, addEventListener: function () {}, removeEventListener: function () {} } } }
|
|
363
|
+
async function deliverContinuableIntervention(sid, taskId, rec, msg) {
|
|
364
|
+
var childId = String((rec && rec.childId) || '')
|
|
365
|
+
var subagents = ctx.subagents
|
|
366
|
+
var parent = rootForSession(sid)
|
|
367
|
+
// 判活口径(主窗口 2026-10-06 干预锐化):continuable rec 的「能不能安全投递」=
|
|
368
|
+
// childId 在 + **未被标记 settled**(立旗 = 结算通道正在认领 / 会话正在收尾,此刻投递要么打在
|
|
369
|
+
// 正在关门的会话上、要么静默丢失);任一不满足即不投,直接落「未实时送达」记录注入。
|
|
370
|
+
// 刻意不照抄 dispatch.mjs 的 recIsLive:那是「幽灵回收」的**卡面证据**口径(注释明写不认
|
|
371
|
+
// rec.settled——抢先摘表项会丢交付物),回答的是「这条表项还算不算在跑」;这里只回答「能不能投」。
|
|
372
|
+
if (!childId || rec.settled === true || !subagents || !parent) return { delivered: false }
|
|
373
|
+
// 干预消息形态与一次性路径**同一个构造**(makeMsg):文案/source 逐字一致,两条通道的存档可对齐比对。
|
|
374
|
+
var m = makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。', 'notice', '高优干预: ' + taskId)
|
|
375
|
+
// 符号键读取本身也包 try:跨进程代理面对未知符号的取属性可能直接抛(取不到就按「老宿主」降级)
|
|
376
|
+
var host = null
|
|
377
|
+
try { host = DELIVER_PROMPT ? subagents[DELIVER_PROMPT] : null } catch (_) { host = null }
|
|
378
|
+
if (typeof host === 'function') {
|
|
379
|
+
try {
|
|
380
|
+
await host.call(subagents, parent, childId, m.content, m.source, makeSignal(), 'steer')
|
|
381
|
+
return { delivered: true, channel: 'steer' }
|
|
382
|
+
} catch (e) { console.error('[task-board] continuable 干预 host 投递失败 (task ' + taskId + '):', String(e)) }
|
|
383
|
+
}
|
|
384
|
+
if (typeof subagents.sendMessage === 'function') {
|
|
385
|
+
try {
|
|
386
|
+
await subagents.sendMessage(parent, childId, m.content, { signal: makeSignal() })
|
|
387
|
+
return { delivered: true, channel: 'steer' }
|
|
388
|
+
} catch (e) { console.error('[task-board] continuable 干预 sendMessage 回退失败 (task ' + taskId + '):', String(e)) }
|
|
389
|
+
}
|
|
390
|
+
return { delivered: false }
|
|
391
|
+
}
|
|
323
392
|
async function doIntervene(sid, actor, taskId, msg) {
|
|
324
393
|
if (!(msg || '').trim()) return { ok: false, error: 'message required' }
|
|
325
394
|
var rec = runsFor(sid)[taskId]
|
|
326
395
|
var delivered = false
|
|
327
396
|
var channel = ''
|
|
328
|
-
|
|
397
|
+
var missedNote = ''
|
|
398
|
+
if (rec && rec.continuable === true) {
|
|
399
|
+
// continuable:没有本地 Agent 句柄,只能经宿主投递(三态降级见上)
|
|
400
|
+
var ivr = await deliverContinuableIntervention(sid, taskId, rec, msg)
|
|
401
|
+
delivered = ivr.delivered === true
|
|
402
|
+
channel = ivr.channel || ''
|
|
403
|
+
if (!delivered) missedNote = '(干预未能实时送达(会话不可用),已转为重派注入)'
|
|
404
|
+
} else if (rec && rec.run && rec.run.localAgent) {
|
|
329
405
|
var agent = rec.run.localAgent
|
|
330
406
|
var m = makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。', 'notice', '高优干预: ' + taskId)
|
|
331
407
|
if (typeof agent.steer === 'function') {
|
|
@@ -335,16 +411,28 @@ export function createRpc(ctx, state, deps) {
|
|
|
335
411
|
try { agent.followup(m); delivered = true; channel = 'followup' } catch (_) {}
|
|
336
412
|
}
|
|
337
413
|
}
|
|
338
|
-
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === taskId }); if (t) { if (!Array.isArray(t.messages)) t.messages = []; t.messages.push({ kind: 'intervention', text: msg, at: new Date().toISOString(), by: actor }); ah(t, t.status, t.status, actor, '高优干预: ' + msg.slice(0, 200) + (delivered ? '' : '(无活跃 run,随下次派发注入)')) }; return t })
|
|
414
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === taskId }); if (t) { if (!Array.isArray(t.messages)) t.messages = []; t.messages.push({ kind: 'intervention', text: msg, at: new Date().toISOString(), by: actor }); ah(t, t.status, t.status, actor, '高优干预: ' + msg.slice(0, 200) + (delivered ? '' : (missedNote || '(无活跃 run,随下次派发注入)'))) }; return t })
|
|
339
415
|
return { ok: true, delivered: delivered, channel: channel }
|
|
340
416
|
}
|
|
341
|
-
// 终止执行某任务的 run
|
|
417
|
+
// 终止执行某任务的 run:一次性 dispose / continuable interrupt 后回 pending(verifying 则保持待审,由新 verifier 接手)
|
|
342
418
|
async function doTerminate(sid, actor, taskId) {
|
|
343
419
|
var rec = runsFor(sid)[taskId]
|
|
344
420
|
var label = 'no-active-run'
|
|
345
421
|
if (rec) {
|
|
346
422
|
delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId
|
|
347
|
-
|
|
423
|
+
// 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
|
|
424
|
+
// 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
|
|
425
|
+
// 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
|
|
426
|
+
// 手动终止**刻意不留续跑资格**:run 结局停在 running(不 closeRunHistory)→ 重派走 fresh spawn,
|
|
427
|
+
// 不把用户刚终止的子会话又唤醒(终止语义 = 重新排队,不是续跑)。
|
|
428
|
+
// interrupt 失败(会话已死 / 无 root 可授权)静默跳过:任务照常回 pending 重排,不卡死。
|
|
429
|
+
try { if (rec.run) await rec.run.dispose() } catch (_) {}
|
|
430
|
+
if (rec.continuable) {
|
|
431
|
+
try {
|
|
432
|
+
var subs = ctx.subagents, rroot = rootForSession(sid)
|
|
433
|
+
if (subs && typeof subs.interrupt === 'function' && rroot) subs.interrupt(rec.childId, { kind: 'ancestor', agent: rroot })
|
|
434
|
+
} catch (_) {}
|
|
435
|
+
}
|
|
348
436
|
// 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
|
|
349
437
|
// 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
|
|
350
438
|
await accumulateRunUsage(sid, rec)
|
|
@@ -397,9 +485,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
397
485
|
if (!t) return { ok: false, error: 'task disappeared' }
|
|
398
486
|
var rec = await spawnOneShot(sid, t, role)
|
|
399
487
|
if (rec) {
|
|
400
|
-
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.claimedBy === 'spawn-pending') t2.claimedBy = String(rec.
|
|
401
|
-
if (role === 'verifier') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2) t2.verifierRun = String(rec.
|
|
402
|
-
return { ok: true, runId: String(rec.
|
|
488
|
+
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.claimedBy === 'spawn-pending') t2.claimedBy = String(rec.id); return t2 }, true) }
|
|
489
|
+
if (role === 'verifier') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2) t2.verifierRun = String(rec.id); return t2 }, true) }
|
|
490
|
+
return { ok: true, runId: String(rec.id) }
|
|
403
491
|
}
|
|
404
492
|
// spawn 失败 → 回退
|
|
405
493
|
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.status === 'in-progress' && t2.claimedBy === 'spawn-pending') { t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null; ah(t2, 'in-progress', 'pending', 'system', 'spawn 失败') }; return t2 }, true) }
|
|
@@ -446,6 +534,14 @@ export function createRpc(ctx, state, deps) {
|
|
|
446
534
|
})
|
|
447
535
|
if (result && result.ok && result.escalated) maybeNotify(sid, result.task)
|
|
448
536
|
if (result && result.ok && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved') // 工具直报路径也要回执(work 档 board_report 直接落 resolved)
|
|
537
|
+
// ===== 上报通道收尾(task-muwkhqf8)=====
|
|
538
|
+
// 工具通道把任务推进到落定态(verifying/resolved)即代表这一轮 Worker 交付完成——此刻就把该 run
|
|
539
|
+
// 的记录收掉(关结局 + usage 落账 + 摘超时臂)。为什么不能只等 agent/status 的 idle:continuable
|
|
540
|
+
// Worker 的 idle 可能压根不到(伪 idle 被 rec.ran 二次守卫挡下 / 冷窗口 / 重启 / 事件丢失),
|
|
541
|
+
// 那时 run 记录永久停在 running → 软超时误报 + usage 漏计 + 硬超时对闲置会话补枪(本卡三症状)。
|
|
542
|
+
// 必须 await:上报返回前收尾落地,「上报成功但记录仍开着」的窗口归零。
|
|
543
|
+
// 失败/无活跃 rec 静默(收尾是附加动作,绝不因它让上报失败);progress/escalate 不在此列。
|
|
544
|
+
if (result && result.ok && result.task && result.task.status !== 'in-progress') await settleReportedRun(result.task.id)
|
|
449
545
|
return result
|
|
450
546
|
} }))
|
|
451
547
|
ctx.tools.register(defineTool({ name: 'board_verdict', description: '[任务看板 Verifier 专用] 提交验收结论。verdict=approved/rejected;summary=测试概要;checks=逐条核对证据。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, summary: { type: 'string' }, checks: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) {
|
|
@@ -517,7 +613,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
517
613
|
// 史诗拆分总开关 epicSplit(UI「功能」小节):布尔原样落盘 + **当场回填 epicSplitCache**——Team 提示词
|
|
518
614
|
// 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
|
|
519
615
|
// 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
|
|
520
|
-
handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
|
|
616
|
+
handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'workerContinuable') d.workerContinuable = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
|
|
521
617
|
handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor = getActorId(); /* hooks 只许主窗口设置(角色门禁) */ if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; return mutateLocked(sid, function (d) { var nHooks = null; if (args.hooks !== undefined) { var hn = normalizeHooks(args.hooks); if (hn.error) return { ok: false, error: hn.error }; nHooks = hn.hooks } if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (nHooks && Object.keys(nHooks).length) t.hooks = nHooks; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ var out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var warns = createTaskWarnings(t); attachContextSuggestions(out, warns, t.touches, existsInSession(sid)); if (warns.length) out.warning = warns.join(';'); return out }) })
|
|
522
618
|
handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
|
|
523
619
|
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
|
package/lib/store.mjs
CHANGED
|
@@ -181,7 +181,32 @@ export function createStore(ctx, state, deps) {
|
|
|
181
181
|
function withLock(sid, fn) { var prev = fileLocks[sid] || Promise.resolve(); var p = prev.then(function () { return fn() }); fileLocks[sid] = p.catch(function () {}); return p }
|
|
182
182
|
// 便捷:串行的 读→mutate→写。mutate(d) 返回值作为结果;mutate 返回 null/undefined 则不写
|
|
183
183
|
// 写成功后异步触发一次 poolCycle(派发/回收反应快),按会话去抖避免连环触发
|
|
184
|
-
|
|
184
|
+
// ===== 去抖 latch 的复位兜底(池冻结防御,task-muwlepg6)=====
|
|
185
|
+
// cyclePending 是「写盘后触发一次 cycle」的去抖闸门:置真 → 50ms 后复位 → 调 poolCycle。
|
|
186
|
+
// 旧实现 `tm.timeout(50).then(go)` 有两个恒真风险,任何一个都让**此后所有写盘都不再触发派发**
|
|
187
|
+
// (症状与「池被冻住」完全一致,且完全静默):
|
|
188
|
+
// ① 定时器 promise reject(插件热重载/宿主 timer 被回收)→ then 的 onFulfilled 永不执行,
|
|
189
|
+
// go 不跑 → cyclePending[sid] 永远为 true;
|
|
190
|
+
// ② 定时器 promise 永不落定(同源的宿主态异常)→ 同上。
|
|
191
|
+
// 修法:① 用 .then(go, go) 让 reject 也走复位;② latch 存**时间戳**而不是布尔,超过
|
|
192
|
+
// STALE_LATCH_MS(10s,正常去抖窗口的 200 倍——正常路径永远碰不到)即视为坏 latch,放行本次触发。
|
|
193
|
+
// ③ poolCycle 调用本身再包一层 try/catch:deps.poolCycle 还没收口(null)时旧写法会在
|
|
194
|
+
// then 回调里同步抛 TypeError → 变成无处理者的 promise 拒绝(reject 后 latch 也复位不了)。
|
|
195
|
+
var STALE_LATCH_MS = 10000
|
|
196
|
+
function kickCycle(sid) {
|
|
197
|
+
var latchedAt = cyclePending[sid]
|
|
198
|
+
if (latchedAt && Date.now() - latchedAt < STALE_LATCH_MS) return // 正常去抖:50ms 窗口内只排一次
|
|
199
|
+
cyclePending[sid] = Date.now()
|
|
200
|
+
var tm = ctx.timer
|
|
201
|
+
var go = function () {
|
|
202
|
+
cyclePending[sid] = 0
|
|
203
|
+
try {
|
|
204
|
+
var p = deps.poolCycle(sid)
|
|
205
|
+
if (p && typeof p.catch === 'function') p.catch(function () {})
|
|
206
|
+
} catch (e) { console.error('[task-board] kickCycle → poolCycle 调用失败:', String(e)) }
|
|
207
|
+
}
|
|
208
|
+
if (tm) tm.timeout(50).then(go, go); else Promise.resolve().then(go)
|
|
209
|
+
}
|
|
185
210
|
function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
|
|
186
211
|
|
|
187
212
|
return { rt: rt, wt: wt, kickCycle: kickCycle, mutateLocked: mutateLocked }
|
package/lib/usage.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// dsh-agent-board — Token 消耗统计(lib/usage.mjs)
|
|
2
|
-
// v4 会话日志定位 / zstd 分帧 / usage
|
|
2
|
+
// v4 会话日志定位 / zstd 分帧 / usage 聚合(含可续跑 Worker 的 seq 水位线增量结算):纯函数 + node 模块,
|
|
3
|
+
// 不碰 ctx 与共享状态。
|
|
3
4
|
// index.mjs 薄壳 re-export findRunLog/readRunUsage/aggregateUsageSummary(对外契约不变);
|
|
4
5
|
// readLogBytes/readLogFrames 新增 export 供 rpc.mjs 的 agent-activity 复用(原 index.mjs 模块内私有)。
|
|
5
6
|
import { zstdDecompressSync } from 'node:zlib'
|
|
@@ -76,25 +77,38 @@ export function readLogFrames(buf, limit) {
|
|
|
76
77
|
return out
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
80
|
+
// ===== 水位线(sinceSeq):可续跑 Worker 的「同一份日志多次结算」防重复计账 =====
|
|
81
|
+
// 背景(task-muw5hhsh 卡3):一次性 run 各自一份日志文件,全量累加即正确;但 continuable 的
|
|
82
|
+
// 硬超时 interrupt 留存 + 重派冷复活续跑**沿用同一个子会话 → 同一份 v4 日志**——一个会话被结算
|
|
83
|
+
// 多次,整份全量累加会把上几轮的 token 反复记进看板(本机实测同一 childId 结算两次=双倍账)。
|
|
84
|
+
// 水位线取事件自带的全序字段 seq(v4 日志每条事件都带 seq,会话内单调递增——用本机真实日志逐帧核对):
|
|
85
|
+
// 结算时只认 seq > sinceSeq 的 assistant/message 增量帧,并把本份日志见过的 maxSeq 回给调用方
|
|
86
|
+
// 落在 t.runs[].usageSeq 上,下一轮结算从该水位继续。本轮完全没产出(maxSeq 不前进)→ 返回 null
|
|
87
|
+
// (不是 0 值对象,调用方按「暂无数据」跳过),绝不再记一笔空账。
|
|
88
|
+
// sinceSeq 缺省(undefined/非正数)=全量累加 → one-shot 路径逐字不变(水位的「首次结算」形态)。
|
|
89
|
+
export function readRunUsage(runId, sessionsRoot, sinceSeq) {
|
|
83
90
|
try {
|
|
84
91
|
var log = findRunLog(runId, sessionsRoot)
|
|
85
92
|
if (!log) return null
|
|
86
|
-
// 整份读取:usage
|
|
93
|
+
// 整份读取:usage 需要全部帧(单次 run 日志量级 MB,结算时只读一次)。
|
|
87
94
|
// 极端超大日志封顶 64MB——只丢最老的历史帧,好过结算路径被一次同步 IO 拖住。
|
|
95
|
+
// 注意与水位线的关系:封顶只可能丢「最老」帧(seq 小的),水位线语义不受影响。
|
|
88
96
|
var buf = readLogBytes(log, 64 * 1024 * 1024)
|
|
89
97
|
if (!buf) return null
|
|
90
98
|
var frames = readLogFrames(buf, 0)
|
|
91
|
-
var out = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, model: '' }
|
|
99
|
+
var out = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, model: '', maxSeq: -1 }
|
|
92
100
|
var hasUsage = false
|
|
101
|
+
var from = numOr0(sinceSeq)
|
|
102
|
+
// 帧序是「从新到旧」,逐帧扫即可;maxSeq 取全份日志的最大 seq(含无 usage 的事件)——
|
|
103
|
+
// 这样「本轮只有工具调用、没有新助手消息」也不会让水位线倒退(下一轮的增量判定仍然正确)。
|
|
93
104
|
for (var fi = 0; fi < frames.length; fi++) {
|
|
94
105
|
var evs = frames[fi]
|
|
95
106
|
for (var i = 0; i < evs.length; i++) {
|
|
96
107
|
var e = evs[i]; var dta = (e && e.data) || {}
|
|
108
|
+
var sq = numOr0(e && e.seq)
|
|
109
|
+
if (sq > out.maxSeq) out.maxSeq = sq
|
|
97
110
|
if (e && e.type === 'request/context' && dta.model && !out.model) out.model = String(dta.model)
|
|
111
|
+
if (from > 0 && sq <= from) continue // 已结算过的历史帧(水位线之前)不再计入
|
|
98
112
|
if (!e || e.type !== 'assistant/message') continue
|
|
99
113
|
var u = dta.usage
|
|
100
114
|
if (!u) continue
|
|
@@ -116,7 +130,8 @@ function numOr0(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
|
|
|
116
130
|
|
|
117
131
|
// 本地日期 key(YYYY-MM-DD):与 dispatch.mjs 的日账记账同一口径。
|
|
118
132
|
// 用本地 getters 拼而不用 toISOString()——UTC 会把晚间消耗挪到次日,「今日消耗」直接错位。
|
|
119
|
-
|
|
133
|
+
// 导出供测试直接钉死口径(dispatch.mjs 仍持自己同名的一份——那边是记账热路径,不动)。
|
|
134
|
+
export function localDayKey(d) {
|
|
120
135
|
var x = d
|
|
121
136
|
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
122
137
|
return x.getFullYear() + '-' + p2(x.getMonth() + 1) + '-' + p2(x.getDate())
|
|
@@ -167,21 +182,145 @@ function dayCell(v) {
|
|
|
167
182
|
return { t: isFinite(n) && n > 0 ? n : 0, e: null }
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
// ===== 统计范围(range)过滤:range = { from, to },本地日 YYYY-MM-DD,两端都含、可各自缺省 =====
|
|
186
|
+
// 为什么在 host 侧过滤:run 级数据(runs[i].usage 五分量 + runs[i].at 时间戳)只在 host,
|
|
187
|
+
// 客户端轮询只拿聚合——范围筛选只能在这一层做,客户端没法自己裁。
|
|
188
|
+
// 口径:run 的本地日落点 localDayKey(run.at) 判 in/out;范围外 run 不计入 total/byModel/top,
|
|
189
|
+
// byDay 也同步裁到范围内(范围外日不入 byDay)。
|
|
190
|
+
// 缺省(无 range / from 与 to 皆空)= 现状逐字不变(parity):不引入任何新的过滤分支语义,
|
|
191
|
+
// 老任务(无 runs 留账)与无 at 的老 run 照旧全量计入。
|
|
192
|
+
function normDayKey(v) {
|
|
193
|
+
if (v === undefined || v === null) return ''
|
|
194
|
+
var s = String(v).trim()
|
|
195
|
+
if (!s) return ''
|
|
196
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : '' // 只认标准本地日 key,脏值当没填(宁可不过滤,不乱裁)
|
|
197
|
+
}
|
|
198
|
+
// 范围是否有效:两端都空视为「无范围」——调用方传 {from:'',to:''} 与不传等价(客户端空范围即此形态)
|
|
199
|
+
function rangeOn(range) {
|
|
200
|
+
if (!range || typeof range !== 'object') return false
|
|
201
|
+
return !!(normDayKey(range.from) || normDayKey(range.to))
|
|
202
|
+
}
|
|
203
|
+
// 日 key 是否落在范围内(字符串比较即可:YYYY-MM-DD 定长零填充,字典序 = 时间序);端点缺省 = 该侧不设限
|
|
204
|
+
function dayInRange(dayKey, range) {
|
|
205
|
+
var d = normDayKey(dayKey)
|
|
206
|
+
if (!d) return false
|
|
207
|
+
var from = normDayKey(range && range.from)
|
|
208
|
+
var to = normDayKey(range && range.to)
|
|
209
|
+
if (from && d < from) return false
|
|
210
|
+
if (to && d > to) return false
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
// 一次 run 的 usage 分量提取:**任务级** t.runs[] 条目上的 usage 是 run 级留账
|
|
214
|
+
// (五分量俱全,dispatch.accumulateRunUsage 结算时按 runId 原样写回),条目同时自带 at/model。
|
|
215
|
+
// 字段位置务必分清(task-muwq9u04 的 bug 根因):t.runs 是 run 记录**数组**,
|
|
216
|
+
// 而 t.usage.runs 只是「结算次数」的**计数**(number)——把后者当数组读会恒得空数组。
|
|
217
|
+
function runUsageOf(r) {
|
|
218
|
+
var ru = r && r.usage
|
|
219
|
+
if (!ru || typeof ru !== 'object') return null
|
|
220
|
+
var tot = numOr0(ru.total)
|
|
221
|
+
if (!tot) return null // 无总量的 run 条目不算一次有效结算(与 readRunUsage 的 hasUsage 同哲学)
|
|
222
|
+
return { total: tot, input: numOr0(ru.input), output: numOr0(ru.output), cacheRead: numOr0(ru.cacheRead), cacheWrite: numOr0(ru.cacheWrite) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// board 级聚合(get-tasks 现算,不落盘额外表):总量 + 输入/输出/缓存读拆分 + 有效合计 + 按模型小计
|
|
226
|
+
// (合计与有效各一份)+ 任务 Top8 + 日账。归档任务同样计入(它们确实消耗过 token);无 usage 的任务跳过。
|
|
227
|
+
// 第二参 range 可选({from,to} 本地日):给定时只计范围内 run(口径见上方 range 段);省略/空范围 = 全量。
|
|
228
|
+
// 【范围过滤的数据源 · task-muwq9u04 修正】run 级留账在**任务级 t.runs[]**(dispatch 写入,条目形如
|
|
229
|
+
// { role, id, at, model, outcome, usage:{五分量} });t.usage.runs 只是「结算次数」计数(number)。
|
|
230
|
+
// 上一版把 uRaw.runs(= t.usage.runs 计数)当数组读 → 真实数据上恒为 [],范围过滤整个退化成
|
|
231
|
+
// 「按任务 updatedAt 单日近似」,于是切范围后 topTasks 与无范围逐字相同(用户活体实证:选 2026-10-05
|
|
232
|
+
// 后 total/byDay 收窄、Top8 第一名仍是全量 48M 的那张卡)。现在改为逐 run 精确裁切。
|
|
233
|
+
export function aggregateUsageSummary(tasks, range) {
|
|
234
|
+
var s = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byModelEff: {}, byDay: {}, topTasks: [] }
|
|
174
235
|
var list = Array.isArray(tasks) ? tasks : []
|
|
236
|
+
var hasRange = rangeOn(range)
|
|
175
237
|
for (var i = 0; i < list.length; i++) {
|
|
176
|
-
var t = list[i]; var
|
|
177
|
-
if (!
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
238
|
+
var t = list[i]; var uRaw = t && t.usage
|
|
239
|
+
if (!uRaw) continue
|
|
240
|
+
// 过滤层只决定「这次任务贡献哪些用量」,下游累加口径(模型/Top/byDay)逐字不动。
|
|
241
|
+
var u = uRaw
|
|
242
|
+
if (hasRange) {
|
|
243
|
+
var trs = Array.isArray(t.runs) ? t.runs : [] // 任务级 run 留档(真实数据源:条目自带 at + model + usage)
|
|
244
|
+
var uSum = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
245
|
+
var models = null
|
|
246
|
+
var modelsE = null
|
|
247
|
+
var picked = 0 // 范围内入选的 run 数(Top8 的「N 次 run」在范围下报它)
|
|
248
|
+
var covered = 0 // 落了 usage 的 run 数(判「有没有 run 级留账可裁」——与 at 无关)
|
|
249
|
+
var modeledTot = 0 // 入选 run 里**记了模型名**的那部分总量(剩下的按任务级占比摊,见下)
|
|
250
|
+
var modeledEff = 0
|
|
251
|
+
for (var ri = 0; ri < trs.length; ri++) {
|
|
252
|
+
var r = trs[ri]
|
|
253
|
+
var ru = runUsageOf(r)
|
|
254
|
+
if (!ru) continue // run 条目还没落 usage(老 run / 未结算):不参与裁切,只影响 covered 计数
|
|
255
|
+
covered++
|
|
256
|
+
if (!dayInRange(dayKeyOf(r && r.at), range)) continue // 范围外 run:不计入任何分量
|
|
257
|
+
picked++
|
|
258
|
+
uSum.total += ru.total; uSum.input += ru.input; uSum.output += ru.output
|
|
259
|
+
uSum.cacheRead += ru.cacheRead; uSum.cacheWrite += ru.cacheWrite
|
|
260
|
+
// 按模型分布同步按范围收窄:模型 key 与结算同源(派发覆盖 > 日志记录),run 留账里没记模型名
|
|
261
|
+
// → 退化为任务级模型小计,但只在本次有 run 入选时摊(否则等于把范围外消耗算进来)。
|
|
262
|
+
// 合计与有效(byModel / byModelEff)同一轮同源累加,两条口径不各算一遍。
|
|
263
|
+
var rm = (r && r.model) || ''
|
|
264
|
+
if (rm) {
|
|
265
|
+
if (!models) { models = {}; modelsE = {} }
|
|
266
|
+
models[rm] = (models[rm] || 0) + ru.total
|
|
267
|
+
modelsE[rm] = (modelsE[rm] || 0) + effectiveTokens(ru)
|
|
268
|
+
modeledTot += ru.total; modeledEff += effectiveTokens(ru)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (picked) {
|
|
272
|
+
// 有 run 入选:模型分布优先用 run 级重算(能精确按范围切)。
|
|
273
|
+
var taskModels = (uRaw.models && typeof uRaw.models === 'object') ? uRaw.models : {}
|
|
274
|
+
var tmKeys = []
|
|
275
|
+
for (var tk in taskModels) { if (Object.prototype.hasOwnProperty.call(taskModels, tk)) tmKeys.push(tk) }
|
|
276
|
+
var tTot = numOr0(uRaw.total)
|
|
277
|
+
// 未被 run 级模型名归属的那部分用量(含「一条都没记模型名」的极端)按任务级模型小计占比摊派:
|
|
278
|
+
// 模型键不凭空消失(真实数据里 r.model 常是空串——派发未覆盖模型时模型名只在日志里,
|
|
279
|
+
// 而 u.models 用的是日志记录的名字),ΣbyModel 也仍与本次入选 total 对齐,不出现
|
|
280
|
+
// 「按模型分布加起来 ≠ 累计」。全部入选 run 都记了模型名(restTot=0)→ 一个字节都不摊,保持精确。
|
|
281
|
+
var restTot = uSum.total - modeledTot
|
|
282
|
+
var restEff = effectiveTokens(uSum) - modeledEff
|
|
283
|
+
if (tmKeys.length && (restTot > 0 || !models)) {
|
|
284
|
+
if (!models) { models = {}; modelsE = {} }
|
|
285
|
+
var ratio = tTot > 0 ? (restTot / tTot) : 0
|
|
286
|
+
var ratioE = tTot > 0 ? (restEff / tTot) : 0
|
|
287
|
+
for (var ti = 0; ti < tmKeys.length; ti++) {
|
|
288
|
+
models[tmKeys[ti]] = (models[tmKeys[ti]] || 0) + Math.round(numOr0(taskModels[tmKeys[ti]]) * ratio)
|
|
289
|
+
modelsE[tmKeys[ti]] = (modelsE[tmKeys[ti]] || 0) + Math.round(numOr0(taskModels[tmKeys[ti]]) * ratioE)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
u = { total: uSum.total, input: uSum.input, output: uSum.output, cacheRead: uSum.cacheRead, cacheWrite: uSum.cacheWrite, models: models || {}, modelEff: modelsE || {}, runs: picked }
|
|
293
|
+
} else if (!covered) {
|
|
294
|
+
// 无 run 级留账可裁(本功能上线前的存量卡;或 runs 条目全都没落 usage):没有 run 级时间戳,
|
|
295
|
+
// 整笔退化为「updatedAt 的本地日」这一近似口径(与 byDay 存量兜底同源);无 updatedAt / 解析失败 →
|
|
296
|
+
// 无日可判 → 范围外(宁可漏不错,绝不把不知何时花的钱算进用户选的范围)。
|
|
297
|
+
var lk0 = dayKeyOf(uRaw.updatedAt)
|
|
298
|
+
if (!lk0 || !dayInRange(lk0, range)) continue
|
|
299
|
+
} else {
|
|
300
|
+
continue // 有 run 留账但范围内一条都没入选(含 run 无 at 的老数据):本任务对本次范围零贡献
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
var uTot = numOr0(u.total)
|
|
304
|
+
if (!uTot) continue // 范围内无用量 → 不计入(也不进 Top,零值条目无信息量)
|
|
305
|
+
var uEff = effectiveTokens(u) // 有效口径单点定义(effectiveTokens),与 ROI 行/近 7 天同源
|
|
306
|
+
s.total += uTot; s.input += numOr0(u.input); s.output += numOr0(u.output)
|
|
307
|
+
s.cacheRead += numOr0(u.cacheRead); s.cacheWrite += numOr0(u.cacheWrite)
|
|
308
|
+
s.effective += uEff
|
|
181
309
|
var ms = u.models || {}
|
|
182
|
-
for (var mk in ms) { if (Object.prototype.hasOwnProperty.call(ms, mk)) s.byModel[mk] = (s.byModel[mk] || 0) + (ms[mk]
|
|
183
|
-
//
|
|
184
|
-
|
|
310
|
+
for (var mk in ms) { if (Object.prototype.hasOwnProperty.call(ms, mk)) s.byModel[mk] = (s.byModel[mk] || 0) + numOr0(ms[mk]) }
|
|
311
|
+
// 按模型有效消耗(byModelEff):口径统一后模型分布行的主数字也要是有效消耗,否则同一块里
|
|
312
|
+
// 「今日有效 3.7M / 单模型 21M」并排自相矛盾。范围路径已按入选 run 精确算出;无范围路径
|
|
313
|
+
// (以及 run 未记模型名的兜底)按该任务「有效/总量」比例摊派——Σ(byModelEff) 与 s.effective
|
|
314
|
+
// 至多差各模型四舍五入的个位数,不制造新的口径矛盾。
|
|
315
|
+
var meEff = (u.modelEff && typeof u.modelEff === 'object') ? u.modelEff : null
|
|
316
|
+
if (meEff) {
|
|
317
|
+
for (var me1 in meEff) { if (Object.prototype.hasOwnProperty.call(meEff, me1)) s.byModelEff[me1] = (s.byModelEff[me1] || 0) + numOr0(meEff[me1]) }
|
|
318
|
+
} else {
|
|
319
|
+
var ratioE2 = uTot > 0 ? (uEff / uTot) : 0
|
|
320
|
+
for (var me2 in ms) { if (Object.prototype.hasOwnProperty.call(ms, me2)) s.byModelEff[me2] = (s.byModelEff[me2] || 0) + Math.round(numOr0(ms[me2]) * ratioE2) }
|
|
321
|
+
}
|
|
322
|
+
// Top8 带有效/缓存读拆分:仪表盘 Top 行主数字取 effective(有效消耗),合计/缓存读进 title 悬浮
|
|
323
|
+
s.topTasks.push({ id: t.id, title: t.title, total: uTot, effective: uEff, cacheRead: numOr0(u.cacheRead), runs: u.runs || 0 })
|
|
185
324
|
// 日账合并:任务自带 byDay(逐日精确)→ 逐 key 累加,并把日单元归一化成 {t,e}
|
|
186
325
|
// (老 number 形态 → {t:n, e:null},e 不可知就不猜;e 为 null 的日聚合后仍是 null)。
|
|
187
326
|
// 存量兜底:本功能上线前结算的老任务没有 byDay,把整笔 total 归到 updatedAt 的本地日——
|
|
@@ -193,6 +332,7 @@ export function aggregateUsageSummary(tasks) {
|
|
|
193
332
|
if (hasBy) {
|
|
194
333
|
for (var dk in by) {
|
|
195
334
|
if (!Object.prototype.hasOwnProperty.call(by, dk)) continue
|
|
335
|
+
if (hasRange && !dayInRange(dk, range)) continue // 范围外日:不进 byDay(日账随范围一起裁,聚合体内不自相矛盾)
|
|
196
336
|
var c = dayCell(by[dk])
|
|
197
337
|
// 累加器初值 e 用 undefined(=还没合并过任何来源),与「已知不可知」(null)区分开——
|
|
198
338
|
// 若初值直接写 null,第一轮 cur.e===null 判定就把已知的有效值自我清空成 null(本实现踩过的坑)。
|
package/package.json
CHANGED