dsh-agent-board 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/rpc.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import * as core from './core.mjs'
9
9
  import path from 'node:path'
10
10
  import fsNode from 'node:fs'
11
- import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary } from './usage.mjs'
11
+ import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary, readMainWindowUsage } from './usage.mjs'
12
12
  import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLesson } from './policy.mjs'
13
13
  import { makeMsg } from './notify.mjs'
14
14
  import { computeHealthHints } from './health.mjs'
@@ -40,8 +40,20 @@ export function createRpc(ctx, state, deps) {
40
40
  var getActorId = deps.getActorId, resolveRoot = deps.resolveRoot, toolSessionId = deps.toolSessionId, rpcSessionId = deps.rpcSessionId
41
41
  var rootForSession = deps.rootForSession, deriveWorkMode = deps.deriveWorkMode, runsFor = deps.runsFor
42
42
  var rt = deps.rt, mutateLocked = deps.mutateLocked
43
+ // 主窗口消耗(task-muwsol23):测试可注入临时日志根;生产 undefined → findRunLog 回退 ~/.dsh/sessions
44
+ var sessionsRoot = (typeof deps.sessionsRoot === 'string' && deps.sessionsRoot) ? deps.sessionsRoot : undefined
43
45
  var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
44
46
  var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
47
+ // 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
48
+ // Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
49
+ // 行为与本次改造前逐字一致(那时本来就没人做这件事)。
50
+ var reportRunSettled = (typeof deps.reportRunSettled === 'function') ? deps.reportRunSettled : null
51
+ // 上报即收尾的统一调用点:失败只记日志、绝不影响工具返回值(收尾是附加动作,工具通道的结果已落定)。
52
+ function settleReportedRun(taskId) {
53
+ if (!reportRunSettled) return Promise.resolve(false)
54
+ return Promise.resolve(reportRunSettled(toolSessionId(), taskId, { outcome: 'completed' }))
55
+ .catch(function (e) { console.error('[task-board] 上报通道 run 收尾失败 (task ' + taskId + '):', String(e)); return false })
56
+ }
45
57
  var pushSysNote = deps.pushSysNote, sessionCwd = deps.sessionCwd // 调研门禁③:epic 发布预检的投递通道与路径解析根
46
58
  // 僵尸 epic 出清(反馈 n-mutma3mmwceq):archive 门禁的活性判定由 index.mjs 接线注入
47
59
  // (runsFor(sid)[taskId] 存在且 !settled 即活跃)。未注入时按「有活跃 run」兜底——
@@ -63,13 +75,16 @@ export function createRpc(ctx, state, deps) {
63
75
  var teamModeCache = state.teamModeCache
64
76
  var feedbackCache = state.feedbackCache
65
77
  var epicSplitCache = state.epicSplitCache
78
+ // 主窗口消耗增量尾读缓存(task-muwsol23):本体在 index.mjs state 构建;测试桩 state 缺字段时就地补
79
+ if (!state.mainWindowUsageCache) state.mainWindowUsageCache = {}
66
80
  // RPC handlers 表必须在最前面初始化:后面的 handle(...) 调用依赖它(var 只提升声明不提升赋值)
67
81
  var handlers = state.handlers
68
82
  function handle(method, fn) { handlers[method] = fn }
69
83
 
70
84
  // ===== 调研门禁③配套:epic 发布预检 =====
71
- // 路径存在性判定:相对路径按「该会话工作区」解析(与 readContextPack 同根,不靠进程 cwd);
85
+ // 路径存在性判定:相对路径按「该会话工作区」解析(不靠进程 cwd);
72
86
  // existsSync 同步轻量,只在 publish 时跑一遍(每子任务 ≤20 条路径,不读全文)。
87
+ // 注意:这是**唯一**还按会话工作区解析路径的地方(派发侧预研清单已不读盘,见 dispatch.readContextPack)。
73
88
  function existsInSession(sid) {
74
89
  var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
75
90
  return function (p) { try { return fsNode.existsSync(path.isAbsolute(p) ? p : (cwd ? path.join(cwd, p) : p)) } catch (_) { return false } }
@@ -100,13 +115,13 @@ export function createRpc(ctx, state, deps) {
100
115
  // ===== Tools =====
101
116
  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
117
  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 将注入给子代理的实际内容(读盘后的最终形态),用于确认材料是否足够。不创建任务。返回 ok=false/empty=true 说明无可注入内容。', parameters: { type: 'object', properties: { contextFiles: { type: 'array', items: { type: 'string' }, description: '预演的文件路径列表' }, contextNotes: { type: 'string', description: '预演的调研笔记' } }, required: [] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); 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 = await readContextPack(sid, { context: { files: files, notes: notes } }); return { ok: true, empty: !pack, pack: pack } } catch (e) { return { ok: false, error: String(e).slice(0, 200) } } } }))
118
+ 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
119
  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
120
  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
121
  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
122
  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: '预研文件路径(替换式更新),派发时经上下文注入传给子代理' }, 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 } }))
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 可选:你在调研中已经读过的关键文件路径数组——派发时 host 会从磁盘读取最新内容,通过「上下文注入」通道传给 Worker/Verifier(呈现为独立注入区块,不占对话流),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 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 立即释放)。' + 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 或绝对路径),内容将在派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),经上下文注入传给子代理' }, 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 }) } }))
123
+ 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 } }))
124
+ 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
125
 
111
126
  // ===== RPC =====
112
127
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
@@ -124,13 +139,28 @@ export function createRpc(ctx, state, deps) {
124
139
  // 这里给出确定布尔值,UI 勾选态不依赖客户端各自的兜底写法。
125
140
  d.notifyDispatch = cfg(d).notifyDispatch
126
141
  d.notifyDone = cfg(d).notifyDone
142
+ // Worker 可续跑开关(workerContinuable,缺省 true):与上面两键同款——读路径用 cfg 归一后的值,
143
+ // 老看板缺字段也返回 true(新行为即默认),客户端不需要自己猜缺省。消费点只在派发引擎。
144
+ d.workerContinuable = cfg(d).workerContinuable
127
145
  // 史诗拆分总开关(设置区「功能」小节)同法显式透出确定布尔值:老 host 不返回时客户端按「缺字段=开」兜底,
128
146
  // UI 勾选态不依赖各自兜底写法。
129
147
  d.epicSplit = cfg(d).epicSplit
130
148
  // 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
131
149
  d.workMode = deriveWorkMode(d)
132
150
  // ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
133
- d.usageSummary = aggregateUsageSummary(d.tasks)
151
+ // 统计范围(args.range 可选 { from, to } 本地日):仪表盘的「统计范围」筛选对 Token 区的
152
+ // 模型分布 / Top8 / 累计生效——run 级数据只在 host,所以过滤必须在 host 做(客户端只拿聚合,没法自己裁)。
153
+ // 缺省/空范围 = 现状逐字不变(aggregateUsageSummary 内部判定,空串不算范围)。
154
+ d.usageSummary = aggregateUsageSummary(d.tasks, args && args.range)
155
+ // ===== 主窗口(本会话对话)消耗单列(task-muwsol23)=====
156
+ // 数据源 = 本看板所属主会话自己的 v4 日志,readMainWindowUsage 增量尾读聚合
157
+ // (缓存 state.mainWindowUsageCache,键=会话 id:文件不变零读、变大只读增量、变小/轮换全量重读一次)。
158
+ // 隔离红线:只挂 usageSummary.mainWindow——**不进 Top8 / 「本看板累计」/ 架构健康与学习飞轮基数**
159
+ // (那些仍只读 d.tasks 的 run 留账,本行挂账发生在聚合完成之后,互不影响)。
160
+ // 随统计范围裁剪(assistant/message 事件本地日落点);读不到/无 usage/任何失败 → null 不挂字段
161
+ // (客户端静默不渲染该行),函数本体全 try/catch 绝不抛进 3s 轮询。
162
+ var __mw = readMainWindowUsage(sid, sessionsRoot, args && args.range, state.mainWindowUsageCache)
163
+ if (__mw) d.usageSummary.mainWindow = __mw
134
164
  // 架构自省 L1:healthHints 现算(纯函数零存储零 IO,近 50 卡窗口),客户端「架构健康」区超阈值才显示
135
165
  d.healthHints = computeHealthHints(d.tasks)
136
166
  // 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, settled, resolved, active, activeTitle } },
@@ -141,21 +171,24 @@ export function createRpc(ctx, state, deps) {
141
171
  // 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify——传输仍全量,省的是渲染
142
172
  d.tasksHash = tasksHash(d.tasks)
143
173
  return d })
144
- // 派发前上下文预览:主 agent 用它确认"我将注入给子代理的材料"是否足够(不发任务、不落盘)
174
+ // 派发前上下文预览:主 agent 用它确认"我将注入给子代理的瘦身清单"是否足够(不发任务、不落盘)
145
175
  handle('preview-context', async function (args) {
176
+ rpcSessionId(args) // 副作用保留:注册已知会话(15s 心跳轮询集合);清单组装本身已不依赖会话工作区
146
177
  var files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []
147
178
  var notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''
148
179
  if (!files.length && !notes.trim()) return { ok: false, error: 'contextFiles/contextNotes 至少提供一个' }
149
180
  var pack = ''
150
- try { pack = await readContextPack(rpcSessionId(args), { context: { files: files, notes: notes } }) } catch (e) { return { ok: false, error: '读取失败: ' + String(e).slice(0, 120) } }
181
+ try { pack = readContextPack({ context: { files: files, notes: notes } }) } catch (e) { return { ok: false, error: '清单组装失败: ' + String(e).slice(0, 120) } }
151
182
  return { ok: true, filesCount: files.length, notesLen: notes.length, pack: pack, empty: !pack }
152
183
  })
153
184
  // 活动心跳:读子代理会话日志的最后一帧,提取最近的动作摘要(卡片/详情页展示"现在跑到哪了")
154
185
  handle('agent-activity', async function (args) {
155
186
  var sid = rpcSessionId(args)
156
187
  var rec = runsFor(sid)[args.taskId]
157
- if (!rec || !rec.run) return { ok: true, activity: null, reason: 'no active run' }
158
- var child = String(rec.run.id)
188
+ if (!rec) return { ok: true, activity: null, reason: 'no active run' }
189
+ // 会话 id 统一取 rec.id:continuable Worker 的 rec 没有 run(rec.run=null,只有 childId),
190
+ // 一次性 run 的 rec.id === rec.run.id——两条路径在这里同构。
191
+ var child = String(rec.id)
159
192
  try {
160
193
  // 子会话日志定位:直接扫描 ~/.dsh/sessions/*/<child>/session.vN.jsonl.zstd
161
194
  // (不再从看板路径反推 workspace——那依赖进程 cwd 凑巧等于工作区,曾是隐性 bug)
@@ -320,12 +353,68 @@ export function createRpc(ctx, state, deps) {
320
353
  // 通道选择(v1.2.4):steer 优先——下一个 step 边界即消费;followup 要等整个 turn 结束,
321
354
  // 长 turn 下干预形同失联(实测:Worker 单 turn 跑 10+ 分钟,「口径重写」类干预到位时活已按旧口径干完)。
322
355
  // steer 不可用(老宿主无此方法)或抛错时回退 followup。
356
+ //
357
+ // ===== continuable 通道补齐(task-muwox2ii,发版门禁 steer 套件 4/8 红的根因)=====
358
+ // 病根:投递条件写的是 rec.run.localAgent——那是**一次性 run 的句柄**;continuable rec 没有 run
359
+ //(持久子会话,卡1 起改由宿主续跑管理器持有,见 spawnOneShot 的 rec 形态),于是整段投递被跳过:
360
+ // 干预没进任何会话、delivered 恒 false、channel 恒 '',Worker 全程收不到指令(steer e2e 的
361
+ // 「上报内容含干预标记」必红),而详情页仍记着「已干预」——人以为送达了。
362
+ // 修法:continuable rec 改走与卡2 续跑同一族通道,三态降级(能实时送达就绝不留成「事后注入」):
363
+ // ① 宿主 host-protocol 投递:Symbol.for('dsh.subagent.deliverPrompt') 是宿主为「适配器保留自有
364
+ // source 描述」留的符号键入口(dsh-subagent 里 Symbol.for 注册、实例上挂同名方法),
365
+ // delivery='steer' → 就近 step 边界消费;走它,子会话日志里记的仍是
366
+ // source.kind='plugin:dsh-agent-board' 的看板消息——与一次性路径 agent.steer(插件消息) 的存档
367
+ // 形态一致(e2e 的「干预被消费」断言口径因此不用改);
368
+ // ② 该符号不可用/抛错(老宿主、跨进程代理面)→ 回退公开 sendMessage(卡2 续跑同通道;宿主内部
369
+ // 也是按 delivery='steer' 投递,只是 source 会被记成 agent-message);
370
+ // ③ 仍然送不到(无 childId / root 不在线 / 会话已死)→ 回退既有记录注入(现状行为:干预文本进
371
+ // t.messages,下次派发随 prompt 注入),并在 history 里写明「未实时送达」,不让它冒充已送达。
372
+ // 一次性 rec 走的仍是原分支:agent.steer → agent.followup 逐字不变。
373
+ var DELIVER_PROMPT = (function () { try { return Symbol.for('dsh.subagent.deliverPrompt') } catch (_) { return null } })()
374
+ // 投递用的取消信号(与 dispatch.mjs 的 makeSignal 同形):只覆盖「入队前」的取消,构造不出来时退化为哑信号。
375
+ function makeSignal() { try { return new AbortController().signal } catch (_) { return { aborted: false, addEventListener: function () {}, removeEventListener: function () {} } } }
376
+ async function deliverContinuableIntervention(sid, taskId, rec, msg) {
377
+ var childId = String((rec && rec.childId) || '')
378
+ var subagents = ctx.subagents
379
+ var parent = rootForSession(sid)
380
+ // 判活口径(主窗口 2026-10-06 干预锐化):continuable rec 的「能不能安全投递」=
381
+ // childId 在 + **未被标记 settled**(立旗 = 结算通道正在认领 / 会话正在收尾,此刻投递要么打在
382
+ // 正在关门的会话上、要么静默丢失);任一不满足即不投,直接落「未实时送达」记录注入。
383
+ // 刻意不照抄 dispatch.mjs 的 recIsLive:那是「幽灵回收」的**卡面证据**口径(注释明写不认
384
+ // rec.settled——抢先摘表项会丢交付物),回答的是「这条表项还算不算在跑」;这里只回答「能不能投」。
385
+ if (!childId || rec.settled === true || !subagents || !parent) return { delivered: false }
386
+ // 干预消息形态与一次性路径**同一个构造**(makeMsg):文案/source 逐字一致,两条通道的存档可对齐比对。
387
+ var m = makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。', 'notice', '高优干预: ' + taskId)
388
+ // 符号键读取本身也包 try:跨进程代理面对未知符号的取属性可能直接抛(取不到就按「老宿主」降级)
389
+ var host = null
390
+ try { host = DELIVER_PROMPT ? subagents[DELIVER_PROMPT] : null } catch (_) { host = null }
391
+ if (typeof host === 'function') {
392
+ try {
393
+ await host.call(subagents, parent, childId, m.content, m.source, makeSignal(), 'steer')
394
+ return { delivered: true, channel: 'steer' }
395
+ } catch (e) { console.error('[task-board] continuable 干预 host 投递失败 (task ' + taskId + '):', String(e)) }
396
+ }
397
+ if (typeof subagents.sendMessage === 'function') {
398
+ try {
399
+ await subagents.sendMessage(parent, childId, m.content, { signal: makeSignal() })
400
+ return { delivered: true, channel: 'steer' }
401
+ } catch (e) { console.error('[task-board] continuable 干预 sendMessage 回退失败 (task ' + taskId + '):', String(e)) }
402
+ }
403
+ return { delivered: false }
404
+ }
323
405
  async function doIntervene(sid, actor, taskId, msg) {
324
406
  if (!(msg || '').trim()) return { ok: false, error: 'message required' }
325
407
  var rec = runsFor(sid)[taskId]
326
408
  var delivered = false
327
409
  var channel = ''
328
- if (rec && rec.run && rec.run.localAgent) {
410
+ var missedNote = ''
411
+ if (rec && rec.continuable === true) {
412
+ // continuable:没有本地 Agent 句柄,只能经宿主投递(三态降级见上)
413
+ var ivr = await deliverContinuableIntervention(sid, taskId, rec, msg)
414
+ delivered = ivr.delivered === true
415
+ channel = ivr.channel || ''
416
+ if (!delivered) missedNote = '(干预未能实时送达(会话不可用),已转为重派注入)'
417
+ } else if (rec && rec.run && rec.run.localAgent) {
329
418
  var agent = rec.run.localAgent
330
419
  var m = makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。', 'notice', '高优干预: ' + taskId)
331
420
  if (typeof agent.steer === 'function') {
@@ -335,16 +424,28 @@ export function createRpc(ctx, state, deps) {
335
424
  try { agent.followup(m); delivered = true; channel = 'followup' } catch (_) {}
336
425
  }
337
426
  }
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 })
427
+ 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
428
  return { ok: true, delivered: delivered, channel: channel }
340
429
  }
341
- // 终止执行某任务的 run:dispose 并回 pending(verifying 则保持待审,由新 verifier 接手)
430
+ // 终止执行某任务的 run:一次性 dispose / continuable interrupt 后回 pending(verifying 则保持待审,由新 verifier 接手)
342
431
  async function doTerminate(sid, actor, taskId) {
343
432
  var rec = runsFor(sid)[taskId]
344
433
  var label = 'no-active-run'
345
434
  if (rec) {
346
435
  delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId
347
- try { await rec.run.dispose() } catch (_) {}
436
+ // 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
437
+ // 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
438
+ // 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
439
+ // 手动终止**刻意不留续跑资格**:run 结局停在 running(不 closeRunHistory)→ 重派走 fresh spawn,
440
+ // 不把用户刚终止的子会话又唤醒(终止语义 = 重新排队,不是续跑)。
441
+ // interrupt 失败(会话已死 / 无 root 可授权)静默跳过:任务照常回 pending 重排,不卡死。
442
+ try { if (rec.run) await rec.run.dispose() } catch (_) {}
443
+ if (rec.continuable) {
444
+ try {
445
+ var subs = ctx.subagents, rroot = rootForSession(sid)
446
+ if (subs && typeof subs.interrupt === 'function' && rroot) subs.interrupt(rec.childId, { kind: 'ancestor', agent: rroot })
447
+ } catch (_) {}
448
+ }
348
449
  // 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
349
450
  // 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
350
451
  await accumulateRunUsage(sid, rec)
@@ -397,9 +498,9 @@ export function createRpc(ctx, state, deps) {
397
498
  if (!t) return { ok: false, error: 'task disappeared' }
398
499
  var rec = await spawnOneShot(sid, t, role)
399
500
  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.run.id); return t2 }, true) }
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.run.id); return t2 }, true) }
402
- return { ok: true, runId: String(rec.run.id) }
501
+ 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) }
502
+ 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) }
503
+ return { ok: true, runId: String(rec.id) }
403
504
  }
404
505
  // spawn 失败 → 回退
405
506
  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 +547,14 @@ export function createRpc(ctx, state, deps) {
446
547
  })
447
548
  if (result && result.ok && result.escalated) maybeNotify(sid, result.task)
448
549
  if (result && result.ok && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved') // 工具直报路径也要回执(work 档 board_report 直接落 resolved)
550
+ // ===== 上报通道收尾(task-muwkhqf8)=====
551
+ // 工具通道把任务推进到落定态(verifying/resolved)即代表这一轮 Worker 交付完成——此刻就把该 run
552
+ // 的记录收掉(关结局 + usage 落账 + 摘超时臂)。为什么不能只等 agent/status 的 idle:continuable
553
+ // Worker 的 idle 可能压根不到(伪 idle 被 rec.ran 二次守卫挡下 / 冷窗口 / 重启 / 事件丢失),
554
+ // 那时 run 记录永久停在 running → 软超时误报 + usage 漏计 + 硬超时对闲置会话补枪(本卡三症状)。
555
+ // 必须 await:上报返回前收尾落地,「上报成功但记录仍开着」的窗口归零。
556
+ // 失败/无活跃 rec 静默(收尾是附加动作,绝不因它让上报失败);progress/escalate 不在此列。
557
+ if (result && result.ok && result.task && result.task.status !== 'in-progress') await settleReportedRun(result.task.id)
449
558
  return result
450
559
  } }))
451
560
  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 +626,7 @@ export function createRpc(ctx, state, deps) {
517
626
  // 史诗拆分总开关 epicSplit(UI「功能」小节):布尔原样落盘 + **当场回填 epicSplitCache**——Team 提示词
518
627
  // 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
519
628
  // 与 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 } }) })
629
+ 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
630
  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
631
  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
632
  // ===== #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
- function kickCycle(sid) { if (cyclePending[sid]) return; cyclePending[sid] = true; var tm = ctx.timer; var go = function () { cyclePending[sid] = false; deps.poolCycle(sid).catch(function () {}) }; if (tm) tm.timeout(50).then(go); else Promise.resolve().then(go) }
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 }