dsh-agent-board 1.8.0 → 1.8.1
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 +4 -3
- package/lib/client/dashboard.js +7 -3
- package/lib/client.js +7 -3
- package/lib/core.mjs +45 -1
- package/lib/dispatch.mjs +132 -7
- package/lib/health.mjs +9 -0
- package/lib/rpc.mjs +136 -15
- package/package.json +1 -1
- package/test/core.test.mjs +220 -5
- package/test/integration.test.mjs +106 -1
- package/test/runtime-health.test.mjs +22 -0
- package/test/settle-stop-interrupt.test.mjs +282 -0
- package/test/verify-timing.test.mjs +127 -2
package/lib/rpc.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { makeMsg } from './notify.mjs'
|
|
|
14
14
|
import { computeHealthHints, computeRuntimeHealthHints } from './health.mjs'
|
|
15
15
|
import { isFullSessionId } from './session.mjs' // 幻影板防线口径(纯函数,与 policy.mjs 直引同例)
|
|
16
16
|
import { previewCleanup, runCleanup, SKIP_REASON_TEXT } from './cleanup.mjs' // 已结算会话清理(预览+执行)
|
|
17
|
-
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, lateVerdictApply, archiveApply, cancelApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint } = core
|
|
17
|
+
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, lateVerdictApply, archiveApply, cancelApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint, appendDispatchWarning } = core
|
|
18
18
|
|
|
19
19
|
function defineTool(options) {
|
|
20
20
|
var userExecute = options.execute
|
|
@@ -131,6 +131,14 @@ export function createRpc(ctx, state, deps) {
|
|
|
131
131
|
var ph = state.poolHealth[sid] || (state.poolHealth[sid] = { bornAt: Date.now() })
|
|
132
132
|
ph.lateRejectNote = { taskId: taskId, at: Date.now() }
|
|
133
133
|
}
|
|
134
|
+
// 终止未生效 healthHint 落点(terminate 假杀根修②,task-mv8jfu1v,事故实证 n-mv8cfndmvfcv):
|
|
135
|
+
// doTerminate 的 interrupt 抛错/无中断能力/补杀核验未过时写。与 lateRejectNote 同款 sticky——
|
|
136
|
+
// 僵尸 Worker 继续写盘是进行中的数据安全事故,亮到人来处理(host 重启才清)。
|
|
137
|
+
function markTerminateFailHealth(sid, taskId, childId) {
|
|
138
|
+
if (!state.poolHealth) state.poolHealth = {}
|
|
139
|
+
var ph = state.poolHealth[sid] || (state.poolHealth[sid] = { bornAt: Date.now() })
|
|
140
|
+
ph.terminateFailNote = { taskId: taskId, childId: String(childId || ''), at: Date.now() }
|
|
141
|
+
}
|
|
134
142
|
// archive 门禁(archive-task RPC 与 task_archive 工具同一口径,返回错误串或 null 放行):
|
|
135
143
|
// 任一 run(含 verifier)仍活跃(outcome running 且无 endedAt / 内存表未 settled)→ 拒绝(先查,宁拦勿漏)
|
|
136
144
|
// resolved / cancelled → 放行(既有行为)
|
|
@@ -227,8 +235,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
227
235
|
ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。Verifier 仍在跑(无 endedAt/run outcome 仍 running)时默认拦截并返回提示;确认提前裁决请加 force:true。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' }, force: { type: 'boolean' } }, 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 __act = activeRunOf(sid, t); if (__act && __act.role === 'verifier' && args.force !== true) return { ok: false, error: 'Verifier 还在跑(run ' + __act.id + '),提前裁决将丢弃它的独立结论。确认提前裁决请加 force:true', verifierActive: __act.id }; 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 }) } }))
|
|
228
236
|
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 t = d.tasks.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 }; return archiveApply(d, t, actor, 'archived') }) } }))
|
|
229
237
|
ctx.tools.register(defineTool({ name: 'task_cancel', description: '取消任务:draft/pending/blocked 态可取消(取消后不再派发,可在归档 tab 恢复待办)。in-progress/verifying 需先 terminate-agent,resolved 请用归档。reason 可选,落流转轨迹。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, reason: { type: 'string', description: '取消理由(可选,随流转轨迹留档)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doCancel(toolSessionId(), getActorId(), args.taskId, args.reason) } }))
|
|
230
|
-
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; delete t.cancelledAt; 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 } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
|
|
231
|
-
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
|
|
238
|
+
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; var __depWarn = null; /* dependsOn 后补软提示(task-mv9yupm4②):在飞卡写依赖时挂返回体 warning 字段 */ 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; /* task-mv9yupm4②:在飞卡(in-progress 含 spawn-pending 占位 / verifying)写依赖只软提示——本次 run 的 prompt 已注入、不受新依赖影响(在途 spawn 由落闸锁内重验 depsSatisfied 兜底否决);不拒写不写历史 */ if (t.status === 'in-progress' || t.status === 'verifying') __depWarn = '卡已在执行中,依赖变更不影响本次 run' } 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; delete t.cancelledAt; 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 } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__depWarn) __out.warning = __depWarn; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
|
|
239
|
+
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(建卡即立即派发:约 1s 内被 Worker 领取烧 token),显式 draft:false 可跳过草稿——不派发的占位/测试卡请显式 draft:true' }, 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); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 warning 族(与 create-task RPC 同口径,core 纯函数):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建 */ var __out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); appendDispatchWarning(__out, d, t); /* 派发预警(task-mvbealtf):将自动派发形态的建卡返回体 lintWarnings 追加一行瞬态提示(不落卡) */ var __warns = createTaskWarnings(t); attachContextSuggestions(__out, __warns, t.touches, existsInSession(sid)); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
|
|
232
240
|
|
|
233
241
|
// ===== RPC =====
|
|
234
242
|
// get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
|
|
@@ -279,6 +287,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
279
287
|
d.epicSplit = cfg(d).epicSplit
|
|
280
288
|
// 自测指南开关(设置区「验收」小节)同法透出:缺字段=开,只有显式 false 才关
|
|
281
289
|
d.verifyUserGuide = cfg(d).verifyUserGuide
|
|
290
|
+
// 板级配置审计行(task-mv9yuq2l)透出:轻量卡外字段,set-board-config 成功写入时落
|
|
291
|
+
// {key, from, to, actor, at}(截尾 50 条);老看板缺字段透出空数组(读路径归一,不落盘)。
|
|
292
|
+
d.configLog = Array.isArray(d.configLog) ? d.configLog.slice(-50) : []
|
|
282
293
|
// 清理保留天数(设置区「存储清理」小节):cfg 归一(缺字段/脏值=7,钳 1-90)后透出确定数值,
|
|
283
294
|
// 客户端设置区天数输入与 StorageCleanup 文案都读这一个字段(口径单一,不各自猜缺省)。
|
|
284
295
|
d.cleanupRetentionDays = cfg(d).cleanupRetentionDays
|
|
@@ -509,7 +520,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
509
520
|
})
|
|
510
521
|
}
|
|
511
522
|
handle('cancel-task', async function (args) { return doCancel(rpcSessionId(args), rpcActor(args), args.taskId, args.reason) })
|
|
512
|
-
handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = rpcActor(args); if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; 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.tags !== undefined) t.tags = Array.isArray(args.tags) ? args.tags.map(function (s) { return String(s).trim() }).filter(function (s) { return s.length > 0 }) : []; 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; delete t.cancelledAt; 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 } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
523
|
+
handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = rpcActor(args); if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; 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; var __depWarn = null; /* dependsOn 后补软提示(task-mv9yupm4②):在飞卡写依赖时挂返回体 warning 字段 */ 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.tags !== undefined) t.tags = Array.isArray(args.tags) ? args.tags.map(function (s) { return String(s).trim() }).filter(function (s) { return s.length > 0 }) : []; 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; /* task-mv9yupm4②:在飞卡(in-progress 含 spawn-pending 占位 / verifying)写依赖只软提示——本次 run 的 prompt 已注入、不受新依赖影响(在途 spawn 由落闸锁内重验 depsSatisfied 兜底否决);不拒写不写历史 */ if (t.status === 'in-progress' || t.status === 'verifying') __depWarn = '卡已在执行中,依赖变更不影响本次 run' } 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; delete t.cancelledAt; 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 } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__depWarn) __out.warning = __depWarn; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
513
524
|
handle('set-board-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.boardMode = args.mode === 'manual' ? 'manual' : 'auto'; if (d.boardMode === 'manual' && d.teamMode) { d.teamMode = false; teamModeCache[sid] = false }; return { ok: true, boardMode: d.boardMode, teamMode: !!d.teamMode, workMode: deriveWorkMode(d) } }) })
|
|
514
525
|
handle('set-team-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.teamMode = !!args.enabled; if (d.teamMode) d.boardMode = 'auto'; teamModeCache[sid] = d.teamMode; return { ok: true, teamMode: d.teamMode, boardMode: d.boardMode, workMode: deriveWorkMode(d) } }) })
|
|
515
526
|
// 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
|
|
@@ -676,21 +687,55 @@ export function createRpc(ctx, state, deps) {
|
|
|
676
687
|
return { ok: true, delivered: delivered, channel: channel, state: state }
|
|
677
688
|
}
|
|
678
689
|
// 终止执行某任务的 run:一次性 dispose / continuable interrupt 后回 pending(verifying 则保持待审,由新 verifier 接手)
|
|
679
|
-
|
|
690
|
+
// opts.hold(根修③,task-mv8jfu1v):true 时同一次 mutateLocked 连带 assignMode='manual'——终止+停派
|
|
691
|
+
// 原子落账。否则「先 terminate 再 task_update manual」两步之间必撞 terminate→pending→poolCycle 抢派
|
|
692
|
+
// 竞态(实证:终止后 70ms 内被 Dispatcher 再派新 Worker);hold 后要再派只能人工 dispatch-task。
|
|
693
|
+
async function doTerminate(sid, actor, taskId, opts) {
|
|
694
|
+
var hold = !!(opts && opts.hold)
|
|
680
695
|
var rec = runsFor(sid)[taskId]
|
|
681
696
|
var label = 'no-active-run'
|
|
697
|
+
// ===== 根修①(task-mv8jfu1v,事故实证 n-mv8cfndmvfcv):无 rec 也要杀 =====
|
|
698
|
+
// 旧逻辑 interrupt/closeRunHistory 全在 if(rec) 里——rec 已不在内存表时(结算竞态被先摘 / 清扫器
|
|
699
|
+
// 先动 / 重启后 reconcile 未跑)terminate 只清认领位,子会话毫发无损继续写盘(ae9cf32a 被 terminate
|
|
700
|
+
// 后又跑了 8 分钟,险毁已验收资产)。修法:无 rec 时读卡面快照,命中「in-progress + claimedBy 是真实
|
|
701
|
+
// 子会话 id + 末条 worker 留档 continuable 且仍 running 且 id 与 claimedBy 一致」(与 reconcile 候选
|
|
702
|
+
// 同口径,状态可疑宁可不动)→ 照样按 claimedBy interrupt 补杀 + 关账卡面 running 留档。
|
|
703
|
+
var cardT = null
|
|
704
|
+
if (!rec) {
|
|
705
|
+
try {
|
|
706
|
+
var snap0 = await rt(sid)
|
|
707
|
+
var list0 = (snap0 && Array.isArray(snap0.tasks)) ? snap0.tasks : []
|
|
708
|
+
for (var fi = 0; fi < list0.length; fi++) { if (list0[fi] && list0[fi].id === taskId) { cardT = list0[fi]; break } }
|
|
709
|
+
} catch (_) {}
|
|
710
|
+
}
|
|
711
|
+
var blindTarget = ''
|
|
712
|
+
if (!rec && cardT && cardT.status === 'in-progress' && cardT.claimedBy && cardT.claimedBy !== 'spawn-pending') {
|
|
713
|
+
var bruns = Array.isArray(cardT.runs) ? cardT.runs : []
|
|
714
|
+
for (var bri = bruns.length - 1; bri >= 0; bri--) {
|
|
715
|
+
var br = bruns[bri]
|
|
716
|
+
if (br && br.role === 'worker') { // 只看末条 worker 留档:更早的会话已被取代(与 resumeTarget 同口径)
|
|
717
|
+
if (br.continuable === true && (!br.outcome || br.outcome === 'running') && !br.endedAt && String(br.id) === String(cardT.claimedBy)) blindTarget = String(cardT.claimedBy)
|
|
718
|
+
break
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// ===== 根修②:失败可见化 =====
|
|
723
|
+
// killFail 非空 = 终止未生效/无法确认(interrupt 抛错 / 宿主无中断能力或无活 root / 补杀核验未过)。
|
|
724
|
+
// 旧代码全部静默跳过——ae9cf32a 事故的帮凶。现在锁外统一 pushSysNote err 级 + healthHint。
|
|
725
|
+
var killFail = '', killWhy = ''
|
|
682
726
|
if (rec) {
|
|
683
727
|
delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId
|
|
684
728
|
// 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
|
|
685
729
|
// 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
|
|
686
730
|
// 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
|
|
687
|
-
// interrupt 失败(会话已死 / 无 root
|
|
731
|
+
// interrupt 失败(会话已死 / 无 root 可授权):任务照常回 pending 重排,不卡死——但不再静默(根修②)。
|
|
688
732
|
try { if (rec.run) await rec.run.dispose() } catch (_) {}
|
|
689
733
|
if (rec.continuable) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
734
|
+
var subs = ctx.subagents, rroot = rootForSession(sid)
|
|
735
|
+
if (subs && typeof subs.interrupt === 'function' && rroot) {
|
|
736
|
+
try { subs.interrupt(rec.childId, { kind: 'ancestor', agent: rroot }) }
|
|
737
|
+
catch (e) { killFail = String(rec.childId || ''); killWhy = 'interrupt 抛错: ' + String(e); console.error('[task-board] terminate interrupt 失败 (task ' + taskId + ' child ' + rec.childId + '):', String(e)) }
|
|
738
|
+
} else { killFail = String(rec.childId || ''); killWhy = '宿主无 interrupt 能力或无活 root 可授权' }
|
|
694
739
|
}
|
|
695
740
|
// 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
|
|
696
741
|
// 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
|
|
@@ -700,6 +745,39 @@ export function createRpc(ctx, state, deps) {
|
|
|
700
745
|
// activeRunOf 的卡面兜底会把它误判为活跃 → archive 永久拒(~50ms 结算窗口)。
|
|
701
746
|
// terminated 不满足 resumeTarget 的续跑判据(只认 timeout/error|incomplete)→ 仍不留续跑资格。
|
|
702
747
|
try { await closeRunHistory(sid, taskId, String(rec.id), 'terminated') } catch (_) {}
|
|
748
|
+
} else if (blindTarget) {
|
|
749
|
+
label = 'worker:' + taskId // 补杀命中:不再是 no-active-run 空转
|
|
750
|
+
var subs2 = ctx.subagents, rroot2 = rootForSession(sid)
|
|
751
|
+
if (subs2 && typeof subs2.interrupt === 'function' && rroot2) {
|
|
752
|
+
try { subs2.interrupt(blindTarget, { kind: 'ancestor', agent: rroot2 }) }
|
|
753
|
+
catch (e) { killFail = blindTarget; killWhy = 'interrupt 抛错: ' + String(e); console.error('[task-board] terminate 补杀 interrupt 抛错 (task ' + taskId + ' child ' + blindTarget + '):', String(e)) }
|
|
754
|
+
} else { killFail = blindTarget; killWhy = '宿主无 interrupt 能力或无活 root 可授权' }
|
|
755
|
+
// 核验(根修②):interrupt 是 fire-and-return(信号发出≠对方已停),且此路径无 rec 可观测,
|
|
756
|
+
// 只能拿 listChildren 目录佐证——子会话已不在目录 = 会话不存在,杀无可杀,核验通过;仍在目录 =
|
|
757
|
+
// 无法确认它停了(continuable 留存语义下成功的 interrupt 也还在列——宁误报勿漏报);listChildren
|
|
758
|
+
// 不可用/超时 = 无法核验,同样摆上台面。核验挂死不能拖住 terminate:3s 超时兜底。
|
|
759
|
+
if (!killFail) {
|
|
760
|
+
if (subs2 && typeof subs2.listChildren === 'function') {
|
|
761
|
+
var kids2 = null
|
|
762
|
+
try {
|
|
763
|
+
kids2 = await Promise.race([
|
|
764
|
+
Promise.resolve(subs2.listChildren(sid, makeSignal())),
|
|
765
|
+
new Promise(function (_, rej) { setTimeout(function () { rej(new Error('listChildren 核验超时')) }, 3000) })
|
|
766
|
+
])
|
|
767
|
+
} catch (e2) { killFail = blindTarget; killWhy = '核验失败: ' + String(e2) }
|
|
768
|
+
if (!killFail) {
|
|
769
|
+
var stillThere = false
|
|
770
|
+
for (var ki = 0; ki < (kids2 || []).length; ki++) { var kc = kids2[ki]; if (kc && String(kc.sessionId || kc.id || '') === blindTarget) { stillThere = true; break } }
|
|
771
|
+
if (stillThere) { killFail = blindTarget; killWhy = '核验未过:子会话仍在目录中,无法确认中断已生效' }
|
|
772
|
+
}
|
|
773
|
+
} else { killFail = blindTarget; killWhy = '宿主无 listChildren,无法核验' }
|
|
774
|
+
}
|
|
775
|
+
// 关账(根修①后半):不只靠 rec.id——按卡面最后一条 outcome==='running' 且无 endedAt 的留档关
|
|
776
|
+
// terminated(否则留档停在 running,activeRunOf 卡面兜底误判活跃 → archive 永久拒,巡检也被误导)。
|
|
777
|
+
var lastRunId = ''
|
|
778
|
+
var trs0 = Array.isArray(cardT.runs) ? cardT.runs : []
|
|
779
|
+
for (var ri0 = trs0.length - 1; ri0 >= 0; ri0--) { var r0e = trs0[ri0]; if (r0e && (!r0e.outcome || r0e.outcome === 'running') && !r0e.endedAt) { lastRunId = String(r0e.id); break } }
|
|
780
|
+
if (lastRunId) { try { await closeRunHistory(sid, taskId, lastRunId, 'terminated') } catch (_) {} }
|
|
703
781
|
}
|
|
704
782
|
await mutateLocked(sid, function (d) {
|
|
705
783
|
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
@@ -716,9 +794,20 @@ export function createRpc(ctx, state, deps) {
|
|
|
716
794
|
// 无活跃 rec 的 stale 卡(重启/已摘除)也要按状态兜底清认领位:in-progress 清 claimedBy 同口径,
|
|
717
795
|
// 否则卡面 verifierRun 仍指向已死 run 的 running 留档 → archive 永久拒(rec 不存在时 rec.role 无从判定)。
|
|
718
796
|
else if (t.status === 'verifying') { t.verifierRun = null; delete t.verifierRunAt; ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
|
|
797
|
+
// hold 原子化(根修③):同一次持锁连带 assignMode='manual'——终止+停派一笔落账,
|
|
798
|
+
// poolCycle 下轮不再自动派发本卡(manual 闸门),堵死 terminate→重派竞态窗。
|
|
799
|
+
if (hold && t.assignMode !== 'manual') { t.assignMode = 'manual'; ah(t, t.status, t.status, actor, 'hold:连带转手动派发(assignMode=manual),不再自动派发') }
|
|
719
800
|
return t
|
|
720
801
|
})
|
|
721
|
-
|
|
802
|
+
// 根修②锁外可见化:err 级系统通知(不带 taskId——卡已翻 pending,带 taskId 会在冲刷时按状态过滤
|
|
803
|
+
// 被丢弃,告警必须送达;任务与子会话 id 写进文案)+ healthHint 红条(sticky,3s 轮询即亮)。
|
|
804
|
+
if (killFail) {
|
|
805
|
+
try { if (typeof pushSysNote === 'function') pushSysNote(sid, '🚨 任务(' + taskId + ')终止未生效:' + killWhy + '——Worker 子会话 ' + killFail + ' 可能仍在跑,请用 interrupt_agent(agent_id: ' + killFail + ')补刀') } catch (_) {}
|
|
806
|
+
markTerminateFailHealth(sid, taskId, killFail)
|
|
807
|
+
}
|
|
808
|
+
var ret = { ok: true, terminated: label }
|
|
809
|
+
if (killFail) ret.killUnconfirmed = killFail // 调用方可直接感知「终止未确认生效」
|
|
810
|
+
return ret
|
|
722
811
|
}
|
|
723
812
|
// 继续等待:清除卡死标记
|
|
724
813
|
async function doDismiss(sid, actor, taskId) {
|
|
@@ -729,9 +818,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
729
818
|
})
|
|
730
819
|
return { ok: true }
|
|
731
820
|
}
|
|
732
|
-
handle('terminate-agent', async function (args) { return doTerminate(rpcSessionId(args), rpcActor(args), args.taskId) })
|
|
821
|
+
handle('terminate-agent', async function (args) { return doTerminate(rpcSessionId(args), rpcActor(args), args.taskId, { hold: args.hold === true }) })
|
|
733
822
|
handle('dismiss-suspect', async function (args) { return doDismiss(rpcSessionId(args), rpcActor(args), args.taskId) })
|
|
734
|
-
ctx.tools.register(defineTool({ name: 'task_terminate', description: 'Team 模式:终止执行某任务的池中 Agent(卡死/跑偏时)。in-progress 任务回 pending 重派,verifying 由新 verifier 接手。', 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: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doTerminate(toolSessionId(), getActorId(), args.taskId) } }))
|
|
823
|
+
ctx.tools.register(defineTool({ name: 'task_terminate', description: 'Team 模式:终止执行某任务的池中 Agent(卡死/跑偏时)。in-progress 任务回 pending 重派,verifying 由新 verifier 接手。hold:true 时同一次落账连带 assignMode=manual(终止+停派原子完成,防 terminate→重派竞态)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, hold: { type: 'boolean', description: 'true 时终止+转手动原子完成:同一次落账连带 assignMode=manual,poolCycle 不再自动派发本卡(再派需人工 dispatch-task)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doTerminate(toolSessionId(), getActorId(), args.taskId, { hold: args.hold === true }) } }))
|
|
735
824
|
// 手动触发单任务派发(manual 模式下"派发给 Worker"按钮,或 auto 模式手动补派)
|
|
736
825
|
// touches 文件级排他:默认尊重文件锁——候选与活动任务 touches 冲突时返回
|
|
737
826
|
// { ok:false, error:'touches-conflict', conflicts:[...] }(客户端 confirm 后带 force:true 重发)。
|
|
@@ -864,6 +953,22 @@ export function createRpc(ctx, state, deps) {
|
|
|
864
953
|
if (notifyLateReject) notifyLateReject(sid, result.task)
|
|
865
954
|
markLateRejectHealth(sid, result.task.id)
|
|
866
955
|
}
|
|
956
|
+
// ===== 结算≠停止根修③(task-mv8wuznb,实证:verifier 迟到交付 verdict 落账后又空转 8h)=====
|
|
957
|
+
// 迟到落账时该 verifier 的活跃 rec 通常早已被幽灵回收(卡落定即失活),settleRunRecord 的补停
|
|
958
|
+
// 打不到它——这里按 actor(= 调本工具的 verifier 子会话 id,能调工具即证明它还活着)直接补一枪。
|
|
959
|
+
// 守卫:actor 非空/非 unknown/非本板 root(主窗口手调 board_verdict 时 actor=root,绝不能打断
|
|
960
|
+
// 自己);宿主无 interrupt 能力或无活 root → 静默跳过;interrupt 抛错只 console.error——
|
|
961
|
+
// 落账已完成,残留空转只影响 token 不影响正确性。与 settle 路径的补停幂等叠加(取消信号可重复发)。
|
|
962
|
+
if (result && result.ok && result.late) {
|
|
963
|
+
try {
|
|
964
|
+
var vsubs = ctx.subagents
|
|
965
|
+
var vactor = String(actor || '')
|
|
966
|
+
if (vsubs && typeof vsubs.interrupt === 'function' && vactor && vactor !== 'unknown' && vactor !== String(sid)) {
|
|
967
|
+
var vroot = rootForSession(sid)
|
|
968
|
+
if (vroot) vsubs.interrupt(vactor, { kind: 'ancestor', agent: vroot })
|
|
969
|
+
}
|
|
970
|
+
} catch (e) { console.error('[task-board] 迟到 verdict 落账后 interrupt verifier 失败(task ' + args.taskId + ' child ' + actor + '):', String(e)) }
|
|
971
|
+
}
|
|
867
972
|
return result
|
|
868
973
|
} }))
|
|
869
974
|
// 枚举当前网关可用模型(list-models handler 与 set-board-config 模型校验共用):
|
|
@@ -914,6 +1019,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
914
1019
|
// 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
|
|
915
1020
|
handle('set-board-config', async function (args) {
|
|
916
1021
|
var sid = rpcSessionId(args)
|
|
1022
|
+
var actor = rpcActor(args) // 审计行 actor:谁改的(工具调用方 agent id,缺省回退会话 id)
|
|
917
1023
|
// ===== 模型字段校验(task-mv73ym2k,实证 n-mv6ezj6vary4:手写显示名当 id → 验收 spawn UNKNOWN_MODEL 秒死×2 + 熔断误报「连续失败」)=====
|
|
918
1024
|
// workerModel/verifierModel 的非空值必须先过合法 id 校验:经 enumModels() 枚举当前网关清单(list-models 同款口径),
|
|
919
1025
|
// 不命中则拒写并返回合法清单样例(前 8 个防刷屏);空串=清除覆盖,放行。
|
|
@@ -931,9 +1037,24 @@ export function createRpc(ctx, state, deps) {
|
|
|
931
1037
|
}
|
|
932
1038
|
}
|
|
933
1039
|
}
|
|
934
|
-
|
|
1040
|
+
// ===== 板级配置审计行(task-mv9yuq2l,实证 n-mv8ki92qnhdq:verifierModel 神隐回退 flash 零留痕无从审计)=====
|
|
1041
|
+
// 白名单键成功写入即追加一行 {key, from, to, actor, at} 到 d.configLog(随看板落盘),截尾留最新 50 条——
|
|
1042
|
+
// 审计是「近期谁动了什么」的线索面,不做无限增长。from/to 取 d 的平铺顶层字段(白名单键全是顶层字段),
|
|
1043
|
+
// to 记钳制/归一后的实际落盘值(审计关心的是真值不是入参)。模型校验拒写在上方已提前 return,
|
|
1044
|
+
// 进不到 mutate;未命中白名单的键(__hit=false)照返 ok:true(老契约不变)但不落审计行(无实际写入)。
|
|
1045
|
+
return mutateLocked(sid, function (d) {
|
|
1046
|
+
var __from = d[args.key] === undefined ? null : d[args.key] // 写前留旧值(JSON 落盘不容 undefined,归一成 null)
|
|
1047
|
+
var __hit = true
|
|
1048
|
+
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 === 'cleanupRetentionDays') d.cleanupRetentionDays = Math.max(1, Math.min(90, Number(args.value) || 7)); 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 === 'verifyUserGuide') d.verifyUserGuide = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } else __hit = false
|
|
1049
|
+
if (__hit) {
|
|
1050
|
+
if (!Array.isArray(d.configLog)) d.configLog = []
|
|
1051
|
+
d.configLog.push({ key: String(args.key), from: __from, to: d[args.key] === undefined ? null : d[args.key], actor: actor, at: new Date().toISOString() })
|
|
1052
|
+
if (d.configLog.length > 50) d.configLog = d.configLog.slice(-50) // 截尾 50 条:只留最新,防无限增长
|
|
1053
|
+
}
|
|
1054
|
+
return { ok: true }
|
|
1055
|
+
})
|
|
935
1056
|
})
|
|
936
|
-
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 = rpcActor(args); /* 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); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 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 }) })
|
|
1057
|
+
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 = rpcActor(args); /* 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); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ var out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); appendDispatchWarning(out, d, t); /* 派发预警(task-mvbealtf):与 task_create 工具同口径,返回体瞬态提示不落卡 */ var warns = createTaskWarnings(t); attachContextSuggestions(out, warns, t.touches, existsInSession(sid)); if (warns.length) out.warning = warns.join(';'); return out }) })
|
|
937
1058
|
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) } } })
|
|
938
1059
|
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
|
|
939
1060
|
// delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
|
package/package.json
CHANGED