dsh-agent-board 1.7.7 → 1.7.8

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
@@ -13,7 +13,7 @@ import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLes
13
13
  import { makeMsg } from './notify.mjs'
14
14
  import { computeHealthHints, computeRuntimeHealthHints } from './health.mjs'
15
15
  import { isFullSessionId } from './session.mjs' // 幻影板防线口径(纯函数,与 policy.mjs 直引同例)
16
- const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, lateVerdictApply, archiveApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint } = core
16
+ 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
17
 
18
18
  function defineTool(options) {
19
19
  var userExecute = options.execute
@@ -45,6 +45,9 @@ export function createRpc(ctx, state, deps) {
45
45
  var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
46
46
  var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
47
47
  var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
48
+ // 手动终止的 run 关账(outcome:'terminated',task-mv1pqy27 死结窗口根修):与状态无关,
49
+ // 凡 terminate 命中该卡的 run 就关。未注入(老宿主/测试桩)→ 静默跳过,行为与改造前逐字一致。
50
+ var closeRunHistory = (typeof deps.closeRunHistory === 'function') ? deps.closeRunHistory : function () {}
48
51
  // 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
49
52
  // Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
50
53
  // 行为与本次改造前逐字一致(那时本来就没人做这件事)。
@@ -183,7 +186,8 @@ export function createRpc(ctx, state, deps) {
183
186
  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 && 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) }) } }))
184
187
  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 }) } }))
185
188
  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') }) } }))
186
- 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 } }; 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 } }))
189
+ 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) } }))
190
+ 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 } }))
187
191
  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); 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); var __warns = createTaskWarnings(t); attachContextSuggestions(__out, __warns, t.touches, existsInSession(sid)); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
188
192
 
189
193
  // ===== RPC =====
@@ -412,7 +416,39 @@ export function createRpc(ctx, state, deps) {
412
416
  return { ok: true, deleted: t.id }
413
417
  })
414
418
  })
415
- handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); 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; 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 })
419
+ // ===== 任务取消通道(task_cancel 工具与 cancel-task RPC 共享,单一行为口径)=====
420
+ // 背景:主窗口作废一张 pending/draft/blocked 卡无正规通道——archive 拒收、update 无 status、
421
+ // delete 是真删(丢留档)。取消=显式放弃:置 cancelled + 留档可检索、可在归档 tab 恢复待办。
422
+ // 状态门禁(与 deleteGate 同居「门禁在接线层」分工):
423
+ // draft / pending / blocked → 允许(未产生执行痕迹)
424
+ // in-progress 且 claimedBy=spawn-pending → 允许(派发瞬时占位,无真实 run,cancelApply 内回退占位)
425
+ // in-progress(真实 run)/ verifying → 拒绝,提示先 terminate-agent 终止
426
+ // resolved → 拒绝,引导用 archive-task(已落定任务留档可检索)
427
+ // cancelled / archived → 幂等 ok(已在取消/归档态)
428
+ // 返回 null(放行)或 { err }(拒绝)或 { mode: 'already' }(幂等)。
429
+ function cancelGate(t) {
430
+ if (t.status === 'in-progress' && t.claimedBy === 'spawn-pending') return null
431
+ if (t.status === 'in-progress' || t.status === 'verifying') return { err: '任务正在执行中,请先用 terminate-agent 终止(in-progress 回待办、verifying 换 verifier 接手)再取消' }
432
+ if (t.status === 'resolved') return { err: '已落定任务请用归档(archive-task),不要取消' }
433
+ if (t.status === 'cancelled' || t.status === 'archived') return { mode: 'already' }
434
+ if (t.status !== 'draft' && t.status !== 'pending' && t.status !== 'blocked') return { err: '任务状态 ' + t.status + ' 不在可取消范围(仅草稿/待办/阻塞可取消)' }
435
+ return null
436
+ }
437
+ async function doCancel(sid, actor, taskId, reason) {
438
+ return mutateLocked(sid, function (d) {
439
+ var t = d.tasks.find(function (x) { return x.id === taskId })
440
+ if (!t) return { ok: false, error: 'not found' }
441
+ var g = cancelGate(t)
442
+ if (g) {
443
+ if (g.mode === 'already') return { ok: true, task: t, alreadyCancelled: true }
444
+ return { ok: false, error: g.err }
445
+ }
446
+ var note = (reason && String(reason).trim()) ? ('取消: ' + String(reason).trim().slice(0, 200)) : '取消'
447
+ return cancelApply(d, t, actor, note)
448
+ })
449
+ }
450
+ handle('cancel-task', async function (args) { return doCancel(rpcSessionId(args), getActorId(), args.taskId, args.reason) })
451
+ handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); 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 })
416
452
  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) } }) })
417
453
  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) } }) })
418
454
  // 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
@@ -559,8 +595,6 @@ export function createRpc(ctx, state, deps) {
559
595
  // 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
560
596
  // 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
561
597
  // 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
562
- // 手动终止**刻意不留续跑资格**:run 结局停在 running(不 closeRunHistory)→ 重派走 fresh spawn,
563
- // 不把用户刚终止的子会话又唤醒(终止语义 = 重新排队,不是续跑)。
564
598
  // interrupt 失败(会话已死 / 无 root 可授权)静默跳过:任务照常回 pending 重排,不卡死。
565
599
  try { if (rec.run) await rec.run.dispose() } catch (_) {}
566
600
  if (rec.continuable) {
@@ -572,16 +606,26 @@ export function createRpc(ctx, state, deps) {
572
606
  // 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
573
607
  // 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
574
608
  await accumulateRunUsage(sid, rec)
609
+ // 关 run 结局 outcome:'terminated'(与状态无关,task-mv1pqy27 死结窗口根修):
610
+ // terminate 命中该卡的 run 就关账,否则卡面 runs 留档停在 running 且无 endedAt,
611
+ // activeRunOf 的卡面兜底会把它误判为活跃 → archive 永久拒(~50ms 结算窗口)。
612
+ // terminated 不满足 resumeTarget 的续跑判据(只认 timeout/error|incomplete)→ 仍不留续跑资格。
613
+ try { await closeRunHistory(sid, taskId, String(rec.id), 'terminated') } catch (_) {}
575
614
  }
576
615
  await mutateLocked(sid, function (d) {
577
616
  var t = d.tasks.find(function (x) { return x.id === taskId })
578
617
  if (!t) return null
579
618
  delete t.stuckSince
619
+ // 认领位清理与状态无关(task-mv1pqy27 死结窗口根修):凡 terminate 命中该卡的 run(rec 在内存表),
620
+ // 按 rec.role 清对应认领位——覆盖 resolved/archived/cancelled 态(卡已落定但 run 尚在结算窗口)的
621
+ // 死结:否则 verifierRun/claimedBy 仍指向已终止 run 的 running 留档 → activeRunOf 卡面兜底误判活跃 → archive 永久拒。
622
+ if (rec) {
623
+ if (rec.role === 'verifier') { t.verifierRun = null; delete t.verifierRunAt }
624
+ else { t.claimedBy = null; t.claimedAt = null }
625
+ }
580
626
  if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', actor, '手动终止,任务重新排队') }
581
- // 验收时序三洞② 回归修复:终止 verifier 时同步清 verifierRun 认领位(与 in-progress 清 claimedBy
582
- // 同口径)。否则卡面 verifierRun 仍指向已终止 run 的 running 留档(terminate 刻意不 closeRunHistory),
583
- // activeRunOf 的卡面兜底(liveEntry 按 verifierRun 匹配)会把已死的 run 误判为活跃 → archive 门禁
584
- // 永久拒绝,而报错文案推荐的补救「先 terminate」再跑一次也是空转(内存表已摘、认领位不清)= 死结。
627
+ // 无活跃 rec 的 stale 卡(重启/已摘除)也要按状态兜底清认领位:in-progress 清 claimedBy 同口径,
628
+ // 否则卡面 verifierRun 仍指向已死 run 的 running 留档 → archive 永久拒(rec 不存在时 rec.role 无从判定)。
585
629
  else if (t.status === 'verifying') { t.verifierRun = null; delete t.verifierRunAt; ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
586
630
  return t
587
631
  })
package/lib/store.mjs CHANGED
@@ -9,6 +9,11 @@ import path from 'node:path'
9
9
  import fsNode from 'node:fs'
10
10
  const { seed, normalizeBoard, vt, boardHome } = core
11
11
 
12
+ // 进程内 tmp 序号(配合 pid + 时间戳拼出唯一 tmp 名):并发 wt() 各自写各自的 tmp,
13
+ // 消灭「两个 chunked 写在同一 p+'.tmp' 上交错 → rename 把拼接怪装上正位」的撕裂根因。
14
+ // 模块级(而非 per-store)是为了同一进程内多个 store 实例(测试/热重载)也不会撞名。
15
+ var tmpSeq = 0
16
+
12
17
  // 腐坏看板自动抢救(P0 根修 task-mv0bl9vg ③):从撕裂/夹残片的 JSON 原文里截取**第一个完整 JSON 值**。
13
18
  // 手法 = JSON.parse 报错 position 截断法(本次事故人工恢复就是这招):整串 parse 失败时,
14
19
  // V8 报错信息含 "position N"(实测 node v24:'{"a":1}{"b":2}' 报 position 18,即第二个值起点),
@@ -108,6 +113,11 @@ export function createStore(ctx, state, deps) {
108
113
  }
109
114
  return false
110
115
  }
116
+ // 锁感知写回(P1 根修 task-mv1e17x9):locked=true(调用方已在 mutateLocked 锁内)→ 直接执行;
117
+ // locked=false(rt 被锁外直读触发,如 poolCycle/rpc/notify)→ 经 withLock 串行。
118
+ // 为什么不能一律 withLock:rt 在 mutateLocked 锁内被调用时,再套 withLock 会链到当前**未落定**的锁上
119
+ // (withLock 是简单 promise 链,不可重入)→ 死锁。故用 locked 标志位区分,锁内直跑、锁外补锁。
120
+ function lockAware(sid, locked, fn) { return locked ? fn() : withLock(sid, fn) }
111
121
  // 接管落盘:两条路径,都保证「接管后看板目录里只剩新 sid 那一个文件」(不残留 .adopt-/.tmp 之类残件——
112
122
  // 残留一份含全部历史卡的旧板 = 用户磁盘上多一份完整看板,下次继承还会把它算成候选)。
113
123
  // ① 主路径 rename(p → np):原子、复用 wt() 同款 EPERM/EBUSY 退避重试。注意 rename 只搬内容,
@@ -116,32 +126,37 @@ export function createStore(ctx, state, deps) {
116
126
  // 读到 ownerSession 不匹配会退回空板,用户视角仍是「看板被清空」)。
117
127
  // ② 兜底 rename 走不通(跨设备/顽固占用)→ wt() 写新板 + unlinkRetry 删旧板。先写后删:
118
128
  // 任何时刻磁盘上至少有一份完整看板。
119
- async function adoptBoard(sid, cand, cwd) {
120
- var p = boardPath(cand.sid), np = boardPath(sid)
121
- // 目标名已被占用 → 绝不接管(防覆盖:本会话看板若只是「这一刻读不到」,覆盖等于把本板换成孤儿板)
122
- if (cand.sid === sid || fsNode.existsSync(np)) return false
123
- var c = null
124
- try { c = JSON.parse(await fsNode.promises.readFile(p, 'utf8')) } catch (e) { console.error('[task-board] 继承失败(读旧板):', String(e)); return false }
125
- if (!vt(c)) return false
126
- c.ownerSession = sid
127
- if (typeof cwd === 'string' && cwd) c.ownerCwd = cwd
128
- if (await renameRetry(p, np)) { // ① 主路径:先改名,再把 ownerSession 改写写回
129
- try {
130
- await wt(sid, c)
131
- console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots)')
132
- return true
133
- } catch (e) {
134
- console.error('[task-board] 继承失败(改写 ownerSession):', String(e))
135
- await renameRetry(np, p) // 回滚到旧名:下次 rt 还能再试,且不留半成品
136
- return false
129
+ // 整段经 lockAware 串行(继承写回归一进锁):locked=false 时并发继承/写盘不会互相交错。
130
+ async function adoptBoard(sid, cand, cwd, locked) {
131
+ return lockAware(sid, locked, async function () {
132
+ var p = boardPath(cand.sid), np = boardPath(sid)
133
+ // 目标名已被占用 → 绝不接管(防覆盖:本会话看板若只是「这一刻读不到」,覆盖等于把本板换成孤儿板)
134
+ if (cand.sid === sid || fsNode.existsSync(np)) return false
135
+ var c = null
136
+ try { c = JSON.parse(await fsNode.promises.readFile(p, 'utf8')) } catch (e) { console.error('[task-board] 继承失败(读旧板):', String(e)); return false }
137
+ if (!vt(c)) return false
138
+ c.ownerSession = sid
139
+ if (typeof cwd === 'string' && cwd) c.ownerCwd = cwd
140
+ if (await renameRetry(p, np)) { // ① 主路径:先改名,再把 ownerSession 改写写回
141
+ try {
142
+ await wt(sid, c)
143
+ console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots)')
144
+ return true
145
+ } catch (e) {
146
+ console.error('[task-board] 继承失败(改写 ownerSession):', String(e))
147
+ await renameRetry(np, p) // 回滚到旧名:下次 rt 还能再试,且不留半成品
148
+ return false
149
+ }
137
150
  }
138
- }
139
- try { await wt(sid, c) } catch (e) { console.error('[task-board] 继承失败(写新板):', String(e)); return false }
140
- await unlinkRetry(p)
141
- console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots;rename 不通,走写新+删旧)')
142
- return true
151
+ try { await wt(sid, c) } catch (e) { console.error('[task-board] 继承失败(写新板):', String(e)); return false }
152
+ await unlinkRetry(p)
153
+ console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots;rename 不通,走写新+删旧)')
154
+ return true
155
+ })
143
156
  }
144
- async function rt(sid) {
157
+ // locked(内部参数):true=调用方已在 mutateLocked 锁内(继承/抢救写回不得再套 withLock,防死锁);
158
+ // 缺省 false=锁外直读(写回经 lockAware 补锁串行)。对外只暴露 rt(sid),锁内调用由 mutateLocked 传 true。
159
+ async function rt(sid, locked) {
145
160
  var r = null
146
161
  try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (e) {
147
162
  // 只有「文件确实不存在(ENOENT)」才触发继承:EACCES/EPERM/被占 等说明本会话看板很可能存在、
@@ -151,7 +166,7 @@ export function createStore(ctx, state, deps) {
151
166
  try {
152
167
  var cwd0 = sessionCwd(sid)
153
168
  var cand = findAdoptableBoardFile(sid, cwd0)
154
- if (cand && await adoptBoard(sid, cand, cwd0)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
169
+ if (cand && await adoptBoard(sid, cand, cwd0, locked)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
155
170
  } catch (e2) { console.error('[task-board] 继承流程异常(降级为空板):', String(e2)) }
156
171
  if (r === null) return seed(sid, sessionCwd(sid))
157
172
  }
@@ -167,45 +182,49 @@ export function createStore(ctx, state, deps) {
167
182
  return seed(sid, sessionCwd(sid))
168
183
  } catch (_) {
169
184
  // JSON 截断/损坏(如强杀打断写盘、中部夹杂另一版本残片)→ 抢救 + 隔离 + 可见化(P0 根修)
170
- return recoverCorrupt(sid, r)
185
+ return recoverCorrupt(sid, r, locked)
171
186
  }
172
187
  }
173
188
  // ===== 腐坏看板:自动 salvage + 隔离可见化(task-mv0bl9vg ②③)=====
174
189
  // 三件套:① 隔离前先 trySalvage 截断抢救(成功则以抢救出的完整 JSON 为底继续);
175
190
  // ② 原始坏文件一律 .corrupt-<ts> 留档(抢救成功=复制留档+写回覆盖,抢救失败=改名隔离;撕裂尾部都不丢);
176
191
  // ③ 追加 healthHints err 级 hint(写 poolHealth[sid].corruptNote)+ notify 通知 owner——不再静默。
177
- async function recoverCorrupt(sid, raw) {
178
- var p = boardPath(sid)
179
- var ts = Date.now()
180
- var corruptPath = p + '.corrupt-' + ts
181
- var salv = trySalvage(raw)
182
- var salvaged = null, kept = 0
183
- if (salv.ok && vt(salv.board)) {
184
- salvaged = normalizeBoard(salv.board)
185
- salvaged.ownerSession = sid // 抢救出的板归属本 sid(与 adoptBoard 同口径)
186
- var cw = sessionCwd(sid)
187
- if (!salvaged.ownerCwd && cw) salvaged.ownerCwd = cw
188
- kept = (salvaged.tasks || []).length
189
- }
190
- if (salvaged) {
191
- // 抢救成功:原始坏内容**复制**留档 .corrupt-<ts>(不动主板,撕裂尾部不丢),
192
- // 再以截断后的完整 JSON 为底原子写回主板继续用;写回失败则下次读盘再抢救(幂等,不留空板空档)。
193
- try { await fsNode.promises.copyFile(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板复制留档失败:', String(e)) }
194
- try {
195
- await wt(sid, salvaged)
196
- teamModeCache[sid] = !!salvaged.teamMode
197
- feedbackCache[sid] = salvaged.feedbackEnabled !== false
198
- epicSplitCache[sid] = salvaged.epicSplit !== false
199
- } catch (e) { console.error('[task-board] 抢救写回失败(原始文件已留档 ' + corruptPath + '):', String(e)) }
200
- recordCorruptNote(sid, { at: ts, salvage: true, kept: kept, file: path.basename(corruptPath) })
201
- console.error('[task-board] board file corrupt, salvaged (kept ' + kept + ' tasks), original quarantined: ' + corruptPath)
202
- return salvaged
203
- }
204
- // 抢救失败:坏文件改名 .corrupt-<ts> 隔离(防反复 poison)+ 空板重启(禁止回写)+ 可见 hint + notify
205
- try { await fsNode.promises.rename(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板隔离改名失败(下次读盘重试):', String(e)) }
206
- recordCorruptNote(sid, { at: ts, salvage: false, kept: 0, file: path.basename(corruptPath) })
207
- console.error('[task-board] board file corrupt, quarantined: ' + corruptPath)
208
- return seedNoPersist(sid)
192
+ // locked 语义同 adoptBoard:锁外直读触发 → 经 withLock 串行(双生 .corrupt 对已实证读路径并发触发写真实存在);
193
+ // mutateLocked 锁内触发 → 直跑。整段(含 copyFile 留档 + wt 写回 / rename 隔离)归一进锁,写回不再 bypass withLock。
194
+ async function recoverCorrupt(sid, raw, locked) {
195
+ return lockAware(sid, locked, async function () {
196
+ var p = boardPath(sid)
197
+ var ts = Date.now()
198
+ var corruptPath = p + '.corrupt-' + ts
199
+ var salv = trySalvage(raw)
200
+ var salvaged = null, kept = 0
201
+ if (salv.ok && vt(salv.board)) {
202
+ salvaged = normalizeBoard(salv.board)
203
+ salvaged.ownerSession = sid // 抢救出的板归属本 sid(与 adoptBoard 同口径)
204
+ var cw = sessionCwd(sid)
205
+ if (!salvaged.ownerCwd && cw) salvaged.ownerCwd = cw
206
+ kept = (salvaged.tasks || []).length
207
+ }
208
+ if (salvaged) {
209
+ // 抢救成功:原始坏内容**复制**留档 .corrupt-<ts>(不动主板,撕裂尾部不丢),
210
+ // 再以截断后的完整 JSON 为底原子写回主板继续用;写回失败则下次读盘再抢救(幂等,不留空板空档)。
211
+ try { await fsNode.promises.copyFile(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板复制留档失败:', String(e)) }
212
+ try {
213
+ await wt(sid, salvaged)
214
+ teamModeCache[sid] = !!salvaged.teamMode
215
+ feedbackCache[sid] = salvaged.feedbackEnabled !== false
216
+ epicSplitCache[sid] = salvaged.epicSplit !== false
217
+ } catch (e) { console.error('[task-board] 抢救写回失败(原始文件已留档 ' + corruptPath + '):', String(e)) }
218
+ recordCorruptNote(sid, { at: ts, salvage: true, kept: kept, file: path.basename(corruptPath) })
219
+ console.error('[task-board] board file corrupt, salvaged (kept ' + kept + ' tasks), original quarantined: ' + corruptPath)
220
+ return salvaged
221
+ }
222
+ // 抢救失败:坏文件改名 .corrupt-<ts> 隔离(防反复 poison)+ 空板重启(禁止回写)+ 可见 hint + notify
223
+ try { await fsNode.promises.rename(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板隔离改名失败(下次读盘重试):', String(e)) }
224
+ recordCorruptNote(sid, { at: ts, salvage: false, kept: 0, file: path.basename(corruptPath) })
225
+ console.error('[task-board] board file corrupt, quarantined: ' + corruptPath)
226
+ return seedNoPersist(sid)
227
+ })
209
228
  }
210
229
  // 腐坏/抢救 hint 落 poolHealth(get-tasks 现算透出 err 级 hint)+ notify 通知 owner
211
230
  function recordCorruptNote(sid, note) {
@@ -237,7 +256,11 @@ export function createStore(ctx, state, deps) {
237
256
  finally { await fh.close().catch(function () {}) }
238
257
  }
239
258
  async function wt(sid, d) {
240
- var c = JSON.stringify(d); var p = boardPath(sid); var tmp = p + '.tmp'
259
+ var c = JSON.stringify(d); var p = boardPath(sid)
260
+ // tmp 唯一名(P1 根修 task-mv1e17x9):pid + 时间戳 + 进程内自增序号——并发 wt() 各自写各自的 tmp,
261
+ // rename 装上的永远是**某一份**完整 JSON(旧实现共享 p+'.tmp':两个并发 chunked 写在同一 tmp 交错,
262
+ // rename 把拼接怪原子装上正位——原子 rename 防写一半,防不住 tmp 本身是拼的)。
263
+ var tmp = p + '.' + process.pid + '.' + Date.now() + '-' + (++tmpSeq) + '.tmp'
241
264
  for (var attempt = 0; attempt < 6; attempt++) {
242
265
  try { await writeTmpFsync(tmp, c); await fsNode.promises.rename(tmp, p); return }
243
266
  catch (e) {
@@ -246,14 +269,15 @@ export function createStore(ctx, state, deps) {
246
269
  await new Promise(function (r) { setTimeout(r, 60 * (attempt + 1)) })
247
270
  continue
248
271
  }
272
+ await unlinkRetry(tmp, 2) // 非瞬时错误:清掉自己的 tmp 残件再抛(不堆积)
249
273
  console.error('[task-board] write:', String(e)); throw e
250
274
  }
251
275
  }
252
276
  // 退避重试耗尽(顽固瞬时占用)→ 降级「先 unlink 目标再 rename」并记录;最后一步失败则抛(不吞)
253
277
  console.error('[task-board] write: rename 直覆失败(重试耗尽),降级 unlink+rename: ' + p)
254
- try { await writeTmpFsync(tmp, c) } catch (e) { console.error('[task-board] write:', String(e)); throw e }
278
+ try { await writeTmpFsync(tmp, c) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
255
279
  await unlinkRetry(p, 6)
256
- await fsNode.promises.rename(tmp, p)
280
+ try { await fsNode.promises.rename(tmp, p) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
257
281
  }
258
282
  // 每会话一条 promise 链,串行化所有 读-改-写,消除并发写竞争
259
283
  function withLock(sid, fn) { var prev = fileLocks[sid] || Promise.resolve(); var p = prev.then(function () { return fn() }); fileLocks[sid] = p.catch(function () {}); return p }
@@ -285,7 +309,7 @@ export function createStore(ctx, state, deps) {
285
309
  }
286
310
  if (tm) tm.timeout(50).then(go, go); else Promise.resolve().then(go)
287
311
  }
288
- 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 }) }
312
+ function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid, true); 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 }) }
289
313
 
290
314
  return { rt: rt, wt: wt, kickCycle: kickCycle, mutateLocked: mutateLocked }
291
315
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.7.7",
4
+ "version": "1.7.8",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {