dsh-agent-board 1.7.6 → 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, 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
@@ -43,7 +43,11 @@ export function createRpc(ctx, state, deps) {
43
43
  // 主窗口消耗(task-muwsol23):测试可注入临时日志根;生产 undefined → findRunLog 回退 ~/.dsh/sessions
44
44
  var sessionsRoot = (typeof deps.sessionsRoot === 'string' && deps.sessionsRoot) ? deps.sessionsRoot : undefined
45
45
  var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
46
+ var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
46
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 () {}
47
51
  // 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
48
52
  // Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
49
53
  // 行为与本次改造前逐字一致(那时本来就没人做这件事)。
@@ -59,7 +63,36 @@ export function createRpc(ctx, state, deps) {
59
63
  // (runsFor(sid)[taskId] 存在且 !settled 即活跃)。未注入时按「有活跃 run」兜底——
60
64
  // 保守保持旧门禁行为(in-progress 一律拒归档),绝不因缺依赖误放走在跑的任务。
61
65
  var hasActiveRun = typeof deps.hasActiveRun === 'function' ? deps.hasActiveRun : function () { return true }
66
+ // ===== 活跃 run 判定(验收时序三洞 ①②,用户 n-mv0ebretgv1o)=====
67
+ // 口径:内存活跃表(runsFor)是「仍活着」的权威信号(未 settled);卡面 runs 留档(outcome 仍
68
+ // running 且无 endedAt、且 id 匹配当前认领位 claimedBy/verifierRun)是持久化兜底(内存表丢失/重启后)。
69
+ // 只匹配「当前认领位」:手动终止/重派会把认领位换新,旧 run 的 running 留档不再被误判为活跃。
70
+ // 返回 { role, id } 或 null。role 归一 verifier/worker(hook run 走 verifierRun 位,但归档/验收
71
+ // 竞态只关心 worker/verifier 两族;未知角色按 worker 兜底文案)。
72
+ function activeRunOf(sid, t) {
73
+ var rec = runsFor(sid)[t.id]
74
+ if (rec && !rec.settled) return { role: rec.role === 'verifier' ? 'verifier' : 'worker', id: String(rec.id) }
75
+ var trs = Array.isArray(t.runs) ? t.runs : []
76
+ function liveEntry(runId, role) {
77
+ if (!runId || runId === 'spawn-pending') return null
78
+ for (var i = trs.length - 1; i >= 0; i--) {
79
+ var r = trs[i]
80
+ if (r && r.role === role && String(r.id) === String(runId) && (!r.outcome || r.outcome === 'running') && !r.endedAt) return r
81
+ }
82
+ return null
83
+ }
84
+ if (liveEntry(t.verifierRun, 'verifier')) return { role: 'verifier', id: String(t.verifierRun) }
85
+ if (liveEntry(t.claimedBy, 'worker')) return { role: 'worker', id: String(t.claimedBy) }
86
+ return null
87
+ }
88
+ // 迟到驳回 healthHint 落点(内存心跳,sticky——host 重启才清):board_verdict 迟到驳回分支写
89
+ function markLateRejectHealth(sid, taskId) {
90
+ if (!state.poolHealth) state.poolHealth = {}
91
+ var ph = state.poolHealth[sid] || (state.poolHealth[sid] = { bornAt: Date.now() })
92
+ ph.lateRejectNote = { taskId: taskId, at: Date.now() }
93
+ }
62
94
  // archive 门禁(archive-task RPC 与 task_archive 工具同一口径,返回错误串或 null 放行):
95
+ // 任一 run(含 verifier)仍活跃(outcome running 且无 endedAt / 内存表未 settled)→ 拒绝(先查,宁拦勿漏)
63
96
  // resolved / cancelled → 放行(既有行为)
64
97
  // in-progress 且无活跃 run → 放行(parentKick 僵尸态出清:direct epic 被
65
98
  // parentKickOnDispatch 推进到 in-progress 后子任务已归档、无 run、claimedBy=null,
@@ -67,6 +100,8 @@ export function createRpc(ctx, state, deps) {
67
100
  // 只能 resetToPending→delete 三段舞)
68
101
  // in-progress 有活跃 run / 其余状态 → 拒绝
69
102
  function archiveErr(sid, t) {
103
+ var act = activeRunOf(sid, t)
104
+ if (act) return '有活跃 run(' + (act.role === 'verifier' ? 'verifier ' : 'worker ') + act.id + ' 仍在跑),等它落定或先 terminate'
70
105
  if (t.status === 'resolved' || t.status === 'cancelled') return null
71
106
  if (t.status === 'in-progress' && !hasActiveRun(sid, t.id)) return null
72
107
  return 'cannot archive'
@@ -149,18 +184,39 @@ export function createRpc(ctx, state, deps) {
149
184
  // 门禁,旧守卫 `t.claimedBy !== actor` 对 null 零容忍 = 谁都解不了,手动兜底被堵死;
150
185
  // claimedBy 非空且非本人时照旧拒(既有守卫语义不变)。
151
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) }) } }))
152
- ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; var __vr = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); __vr.hint = REJECT_REDISPATCH_HINT } return __vr }) } }))
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 }) } }))
153
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') }) } }))
154
- 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 } }))
155
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 }) } }))
156
192
 
157
193
  // ===== RPC =====
158
194
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
159
195
  // 客户端 3s 轮询不再触发池计算/写盘(之前每轮询一次就 poolCycle+写盘一次,切会话时多会话轮询挤在文件锁上)
160
- // isRoot 现算不落盘:生成开始/结束瞬间 agents 树重建,roots() 存在瞬态窗口返回不含本 sid(假 false)。
161
- // host 不改行为(保持"现算真相"),防抖在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次
162
- // false(3s 心跳≈9s)才收抽屉;从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
163
- handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; var __ag = ctx.agents; d.isRoot = true; if (__ag) { var __roots = __ag.roots(); var __rids = []; for (var __i = 0; __i < __roots.length; __i++) __rids.push(String(__roots[__i].id)); d.isRoot = __rids.indexOf(sid) >= 0 }
196
+ // isRoot 持久语义(banner 入口瞬态/持久消失修复):根会话=无父(持久 parentSession 缺省),不再读活跃 roots() 集。
197
+ // 旧实现 roots() 只含活体会话——休眠根会话(有板有卡)恒 false,且生成开始/结束 agents 树重建有瞬态窗口
198
+ // 返回不含本 sid(假 false),入口分钟级消失。改读持久 parentSession(见 isRootPersistent):
199
+ // 主源 sessionPersistence.stat(sid)(持久表,休眠会话也读得到)→ header.parentSession 缺省 = 根;
200
+ // 老宿主/测试桩无 sessionPersistence → 回退活跃 agent 的 session.header.parentSession,再回退旧 roots() 成员判定。
201
+ // 防抖仍在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次 false(3s 心跳≈9s)才收抽屉;
202
+ // 从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
203
+ async function isRootPersistent(sid) {
204
+ var sp = ctx.get && ctx.get('sessionPersistence')
205
+ if (sp && typeof sp.stat === 'function') {
206
+ try { var snap = await sp.stat(sid); if (snap && snap.header) return !snap.header.parentSession } catch (_) {}
207
+ }
208
+ var ag = ctx.agents
209
+ if (ag) {
210
+ var live = typeof ag.get === 'function' ? ag.get(sid) : undefined
211
+ if (live && live.session && live.session.header && live.session.header.parentSession) return false
212
+ var roots = ag.roots()
213
+ var rids = []
214
+ for (var i = 0; i < roots.length; i++) rids.push(String(roots[i].id))
215
+ return rids.indexOf(sid) >= 0
216
+ }
217
+ return true
218
+ }
219
+ handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; d.isRoot = await isRootPersistent(sid)
164
220
  // poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
165
221
  if (d.poolStatus) { var __act = {}; (d.tasks || []).forEach(function (t) { if (t.status === 'in-progress' || t.status === 'verifying') __act[t.id] = true }); d.poolStatus.workers = (d.poolStatus.workers || []).filter(function (w) { return __act[w.taskId] }); d.poolStatus.verifiers = (d.poolStatus.verifiers || []).filter(function (v) { return __act[v.taskId] }) }
166
222
  // 学习飞轮 v1:把 feedbackEnabled 显式放进返回体(normalizeBoard 已按老看板补默认 true),
@@ -224,6 +280,15 @@ export function createRpc(ctx, state, deps) {
224
280
  // 父卡列位置/进度展示的数据源;total 含已归档子任务,settled=resolved|cancelled|archived(resolved 为
225
281
  // 兼容别名同值),因此归档子卡不会让进度分母缩水(task-muupgfot);只有无任何子任务的父卡才不出键
226
282
  d.childStats = aggregateChildStats(d.tasks)
283
+ // ===== 风险队列「待你过目」:reviewHint 现算不落库(异常驱动审视①,task-muzikj1y)=====
284
+ // 四硬信号 + 两软信号(软信号仅质量异动告警激活期间计入,读 __qch 非空)现算后挂到每张卡,
285
+ // get-tasks 透出;唯一新落库字段=reviewedAt(mark-reviewed RPC 落)。挂在 tasksHash 之前——
286
+ // reviewHint + reviewedAt 都驱动列表渲染,须进 hash 序列化口径(否则已阅/软信号联动不重渲染)。
287
+ var __rhints = core.computeReviewHints(d.tasks, { qualityAlertActive: __qch.length > 0 })
288
+ for (var __ri = 0; __ri < (d.tasks || []).length; __ri++) {
289
+ var __rkt = d.tasks[__ri]
290
+ if (__rkt && __rhints[__rkt.id]) __rkt.reviewHint = __rhints[__rkt.id]
291
+ }
227
292
  // 渲染变更检测(反馈 n-mut9rzs2mkhg):tasks 关键字段的稳定 hash(纯函数,口径见 core.tasksHash),
228
293
  // 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify——传输仍全量,省的是渲染
229
294
  d.tasksHash = tasksHash(d.tasks)
@@ -308,8 +373,13 @@ export function createRpc(ctx, state, deps) {
308
373
  })
309
374
  handle('claim-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, 'manual claim via board'); return { ok: true, task: t } }) })
310
375
  handle('resolve-task', async function (args) { var sid = rpcSessionId(args); 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' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) })
311
- handle('verify-task', async function (args) { var sid = rpcSessionId(args); 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' }; delete t.escalation; delete t.verifyRetries; var r = 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); pushRejectLesson(d, t, args.comment); r.hint = REJECT_REDISPATCH_HINT } return r }) })
376
+ handle('verify-task', async function (args) { var sid = rpcSessionId(args); 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 }; delete t.escalation; delete t.verifyRetries; var r = 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); pushRejectLesson(d, t, args.comment); r.hint = REJECT_REDISPATCH_HINT } return r }) })
312
377
  handle('archive-task', async function (args) { var sid = rpcSessionId(args); 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, 'manual archive') }) })
378
+ // ===== 已阅落账(异常驱动审视①③):打开详情页即幂等落 reviewedAt(唯一新落库字段)=====
379
+ // 幂等只写一次:已有 reviewedAt 就原样返回、绝不重写(时间戳不漂移、history 不刷屏);
380
+ // 后续重复打开详情页零副作用。reviewedAt 进 tasksHash 序列化口径 → 落账后下一轮 get-tasks hash 变化,
381
+ // 客户端列表徽章/chip 即时消失(配合 kernel.fetchTasks 的 hash 短路渲染节约)。
382
+ handle('mark-reviewed', async function (args) { var sid = rpcSessionId(args); 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.reviewedAt) t.reviewedAt = new Date().toISOString(); return { ok: true, taskId: t.id, reviewedAt: t.reviewedAt } }) })
313
383
  // ===== 任务删除通道(真删,无 undo)=====
314
384
  // 背景:archive-task 只收 resolved/cancelled,草稿/误建卡片此前没有任何下线通道(只能永远挂着)。
315
385
  // 状态门禁(delete-task 与 batch-op delete 共用本函数,保证两条入口语义完全一致):
@@ -346,7 +416,39 @@ export function createRpc(ctx, state, deps) {
346
416
  return { ok: true, deleted: t.id }
347
417
  })
348
418
  })
349
- 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.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 })
350
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) } }) })
351
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) } }) })
352
454
  // 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
@@ -493,8 +595,6 @@ export function createRpc(ctx, state, deps) {
493
595
  // 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
494
596
  // 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
495
597
  // 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
496
- // 手动终止**刻意不留续跑资格**:run 结局停在 running(不 closeRunHistory)→ 重派走 fresh spawn,
497
- // 不把用户刚终止的子会话又唤醒(终止语义 = 重新排队,不是续跑)。
498
598
  // interrupt 失败(会话已死 / 无 root 可授权)静默跳过:任务照常回 pending 重排,不卡死。
499
599
  try { if (rec.run) await rec.run.dispose() } catch (_) {}
500
600
  if (rec.continuable) {
@@ -506,13 +606,27 @@ export function createRpc(ctx, state, deps) {
506
606
  // 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
507
607
  // 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
508
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 (_) {}
509
614
  }
510
615
  await mutateLocked(sid, function (d) {
511
616
  var t = d.tasks.find(function (x) { return x.id === taskId })
512
617
  if (!t) return null
513
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
+ }
514
626
  if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', actor, '手动终止,任务重新排队') }
515
- else if (t.status === 'verifying') { ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
627
+ // 无活跃 rec 的 stale 卡(重启/已摘除)也要按状态兜底清认领位:in-progress 清 claimedBy 同口径,
628
+ // 否则卡面 verifierRun 仍指向已死 run 的 running 留档 → archive 永久拒(rec 不存在时 rec.role 无从判定)。
629
+ else if (t.status === 'verifying') { t.verifierRun = null; delete t.verifierRunAt; ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
516
630
  return t
517
631
  })
518
632
  return { ok: true, terminated: label }
@@ -620,7 +734,14 @@ export function createRpc(ctx, state, deps) {
620
734
  var result = await mutateLocked(sid, function (d) {
621
735
  var t = d.tasks.find(function (x) { return x.id === args.taskId })
622
736
  if (!t) return { ok: false, error: 'not found' }
623
- if (t.status !== 'verifying') return { ok: false, error: 'not verifying (状态: ' + t.status + ')' }
737
+ if (t.status !== 'verifying') {
738
+ // 迟到落账(验收时序三洞 ③):卡已落定(resolved/archived/cancelled)→ verifier 结论不再静默丢弃
739
+ if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
740
+ var lr = lateVerdictApply(t, args.verdict, args.summary, args.checks, args.userTest, new Date().toISOString(), actor, cfg(d).verifyUserGuide)
741
+ return { ok: true, late: true, lateRejected: lr.lateRejected && lr.wasApproved, task: t }
742
+ }
743
+ return { ok: false, error: 'not verifying (状态: ' + t.status + ')' }
744
+ }
624
745
  delete t.escalation; delete t.verifyRetries // verifier 恢复产出:清人工验收标记
625
746
  t.verification = { verdict: args.verdict, summary: args.summary || '', checks: args.checks || '', at: new Date().toISOString(), by: actor }
626
747
  // 自测指南落账(verifyUserGuide 开关门禁,task-muxyyvg0):normalizeUserTest 归一(tier 三档
@@ -636,16 +757,21 @@ export function createRpc(ctx, state, deps) {
636
757
  if (!approved) pushRejection(t, args.summary, args.checks, t.verification.at, actor)
637
758
  return { ok: true, task: t }
638
759
  })
639
- if (result && result.ok && result.task) {
760
+ if (result && result.ok && result.task && !result.late) {
640
761
  if (result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
641
762
  if (result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
642
763
  }
643
- if (result && result.ok && !approved && result.task) {
764
+ if (result && result.ok && !approved && result.task && !result.late) {
644
765
  // v74 一次性模型:原 Worker 已销毁,驳回任务回 pending 重派新 Worker(完整驳回包已落 t.messages,
645
766
  // 经 buildMessages 全量随 prompt 注入;history 只是审计轨)
646
767
  var bt = result.task
647
768
  if ((bt.rejectCount || 0) < 3) { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === bt.id }); if (t2 && t2.status === 'in-progress') { t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null }; return t2 }, true) }
648
769
  }
770
+ // 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知(回执直投 owner,不走开关)+ healthHint
771
+ if (result && result.ok && result.late && result.lateRejected) {
772
+ if (notifyLateReject) notifyLateReject(sid, result.task)
773
+ markLateRejectHealth(sid, result.task.id)
774
+ }
649
775
  return result
650
776
  } }))
651
777
  // 枚举当前网关可用模型(供 Verifier 模型下拉选择):llm.listProviders + listModels
@@ -715,6 +841,8 @@ export function createRpc(ctx, state, deps) {
715
841
  console.error('[task-board] batch delete: ' + id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
716
842
  done++
717
843
  } else if (args.op === 'archive') {
844
+ var __act = activeRunOf(sid, t)
845
+ if (__act) { skip(id, '有活跃 run(' + (__act.role === 'verifier' ? 'verifier ' : 'worker ') + __act.id + ' 仍在跑),等它落定或先 terminate'); return }
718
846
  if (t.status !== 'resolved' && t.status !== 'cancelled') { skip(id, 'cannot archive'); return }
719
847
  var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'batch archive'); done++
720
848
  archived.push(t)
package/lib/store.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // dsh-agent-board — 看板持久化层(lib/store.mjs)
2
- // createStore(ctx, state, deps):家目录绝对路径直读写 / 原子写盘(tmp+rename+瞬时占用退避)/
3
- // 跨重启继承(唯一孤儿板接管)/ 每会话文件锁串行化读-改-写 / mutateLocked + kickCycle 派发触发。
2
+ // createStore(ctx, state, deps):家目录绝对路径直读写 / 原子写盘(tmp+fsync+rename+瞬时占用退避+unlink 兜底)/
3
+ // 跨重启继承(唯一孤儿板接管)/ 腐坏隔离可见化 + 自动 salvage(截断抢救)/
4
+ // 每会话文件锁串行化读-改-写 / mutateLocked + kickCycle 派发触发。
4
5
  // deps.poolCycle 为晚绑定(index.mjs 在 dispatch 创建后收口):kickCycle 经 50ms 去抖才调用,
5
6
  // 而 kickCycle 只可能被工具/RPC/心跳写盘触发(全部发生在 apply 接线完成之后),晚绑定无窗口风险。
6
7
  import * as core from './core.mjs'
@@ -8,6 +9,29 @@ import path from 'node:path'
8
9
  import fsNode from 'node:fs'
9
10
  const { seed, normalizeBoard, vt, boardHome } = core
10
11
 
12
+ // 进程内 tmp 序号(配合 pid + 时间戳拼出唯一 tmp 名):并发 wt() 各自写各自的 tmp,
13
+ // 消灭「两个 chunked 写在同一 p+'.tmp' 上交错 → rename 把拼接怪装上正位」的撕裂根因。
14
+ // 模块级(而非 per-store)是为了同一进程内多个 store 实例(测试/热重载)也不会撞名。
15
+ var tmpSeq = 0
16
+
17
+ // 腐坏看板自动抢救(P0 根修 task-mv0bl9vg ③):从撕裂/夹残片的 JSON 原文里截取**第一个完整 JSON 值**。
18
+ // 手法 = JSON.parse 报错 position 截断法(本次事故人工恢复就是这招):整串 parse 失败时,
19
+ // V8 报错信息含 "position N"(实测 node v24:'{"a":1}{"b":2}' 报 position 18,即第二个值起点),
20
+ // 截到 N 再 parse 即得第一个完整值;无 position(如单对象中部截断 "Unexpected end of JSON input")或
21
+ // 截断后仍 parse 失败 → 返回 { ok:false },交由调用方隔离空板。
22
+ function trySalvage(raw) {
23
+ if (typeof raw !== 'string' || !raw) return { ok: false }
24
+ try { var d = JSON.parse(raw); return d && typeof d === 'object' ? { ok: true, board: d } : { ok: false } }
25
+ catch (e) {
26
+ var m = /position (\d+)/.exec(String(e && e.message))
27
+ if (!m) return { ok: false }
28
+ var pos = parseInt(m[1], 10)
29
+ if (!(pos > 0)) return { ok: false }
30
+ try { var v = JSON.parse(raw.slice(0, pos)); return v && typeof v === 'object' ? { ok: true, board: v } : { ok: false } }
31
+ catch (_) { return { ok: false } }
32
+ }
33
+ }
34
+
11
35
  export function createStore(ctx, state, deps) {
12
36
  var sessionCwd = deps.sessionCwd
13
37
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
@@ -16,6 +40,7 @@ export function createStore(ctx, state, deps) {
16
40
  var epicSplitCache = state.epicSplitCache
17
41
  var fileLocks = state.fileLocks
18
42
  var cyclePending = state.cyclePending
43
+ var poolHealth = state.poolHealth || (state.poolHealth = {}) // 腐坏隔离/抢救 hint 落这里(与 runtime health 同表)
19
44
 
20
45
  // 看板文件用「家目录绝对路径 + node:fs」直读写——不再走 fs 服务的相对路径解析
21
46
  // (fs 服务的相对路径解析根 = 进程启动 cwd,cwd 一变所有看板静默读成空板;
@@ -88,6 +113,11 @@ export function createStore(ctx, state, deps) {
88
113
  }
89
114
  return false
90
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) }
91
121
  // 接管落盘:两条路径,都保证「接管后看板目录里只剩新 sid 那一个文件」(不残留 .adopt-/.tmp 之类残件——
92
122
  // 残留一份含全部历史卡的旧板 = 用户磁盘上多一份完整看板,下次继承还会把它算成候选)。
93
123
  // ① 主路径 rename(p → np):原子、复用 wt() 同款 EPERM/EBUSY 退避重试。注意 rename 只搬内容,
@@ -96,32 +126,37 @@ export function createStore(ctx, state, deps) {
96
126
  // 读到 ownerSession 不匹配会退回空板,用户视角仍是「看板被清空」)。
97
127
  // ② 兜底 rename 走不通(跨设备/顽固占用)→ wt() 写新板 + unlinkRetry 删旧板。先写后删:
98
128
  // 任何时刻磁盘上至少有一份完整看板。
99
- async function adoptBoard(sid, cand, cwd) {
100
- var p = boardPath(cand.sid), np = boardPath(sid)
101
- // 目标名已被占用 → 绝不接管(防覆盖:本会话看板若只是「这一刻读不到」,覆盖等于把本板换成孤儿板)
102
- if (cand.sid === sid || fsNode.existsSync(np)) return false
103
- var c = null
104
- try { c = JSON.parse(await fsNode.promises.readFile(p, 'utf8')) } catch (e) { console.error('[task-board] 继承失败(读旧板):', String(e)); return false }
105
- if (!vt(c)) return false
106
- c.ownerSession = sid
107
- if (typeof cwd === 'string' && cwd) c.ownerCwd = cwd
108
- if (await renameRetry(p, np)) { // ① 主路径:先改名,再把 ownerSession 改写写回
109
- try {
110
- await wt(sid, c)
111
- console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots)')
112
- return true
113
- } catch (e) {
114
- console.error('[task-board] 继承失败(改写 ownerSession):', String(e))
115
- await renameRetry(np, p) // 回滚到旧名:下次 rt 还能再试,且不留半成品
116
- 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
+ }
117
150
  }
118
- }
119
- try { await wt(sid, c) } catch (e) { console.error('[task-board] 继承失败(写新板):', String(e)); return false }
120
- await unlinkRetry(p)
121
- console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots;rename 不通,走写新+删旧)')
122
- 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
+ })
123
156
  }
124
- async function rt(sid) {
157
+ // locked(内部参数):true=调用方已在 mutateLocked 锁内(继承/抢救写回不得再套 withLock,防死锁);
158
+ // 缺省 false=锁外直读(写回经 lockAware 补锁串行)。对外只暴露 rt(sid),锁内调用由 mutateLocked 传 true。
159
+ async function rt(sid, locked) {
125
160
  var r = null
126
161
  try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (e) {
127
162
  // 只有「文件确实不存在(ENOENT)」才触发继承:EACCES/EPERM/被占 等说明本会话看板很可能存在、
@@ -131,7 +166,7 @@ export function createStore(ctx, state, deps) {
131
166
  try {
132
167
  var cwd0 = sessionCwd(sid)
133
168
  var cand = findAdoptableBoardFile(sid, cwd0)
134
- 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')
135
170
  } catch (e2) { console.error('[task-board] 继承流程异常(降级为空板):', String(e2)) }
136
171
  if (r === null) return seed(sid, sessionCwd(sid))
137
172
  }
@@ -146,36 +181,103 @@ export function createStore(ctx, state, deps) {
146
181
  }
147
182
  return seed(sid, sessionCwd(sid))
148
183
  } catch (_) {
149
- // JSON 截断/损坏(如强杀打断写盘):隔离留档再种新板——数据不丢,坏文件也不反复 poison
150
- console.error('[task-board] board file corrupt, quarantining: ' + boardPath(sid))
151
- fsNode.promises.rename(boardPath(sid), boardPath(sid) + '.corrupt-' + Date.now()).catch(function () {})
184
+ // JSON 截断/损坏(如强杀打断写盘、中部夹杂另一版本残片)→ 抢救 + 隔离 + 可见化(P0 根修)
185
+ return recoverCorrupt(sid, r, locked)
186
+ }
187
+ }
188
+ // ===== 腐坏看板:自动 salvage + 隔离可见化(task-mv0bl9vg ②③)=====
189
+ // 三件套:① 隔离前先 trySalvage 截断抢救(成功则以抢救出的完整 JSON 为底继续);
190
+ // ② 原始坏文件一律 .corrupt-<ts> 留档(抢救成功=复制留档+写回覆盖,抢救失败=改名隔离;撕裂尾部都不丢);
191
+ // ③ 追加 healthHints err 级 hint(写 poolHealth[sid].corruptNote)+ notify 通知 owner——不再静默。
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)
152
226
  return seedNoPersist(sid)
227
+ })
228
+ }
229
+ // 腐坏/抢救 hint 落 poolHealth(get-tasks 现算透出 err 级 hint)+ notify 通知 owner
230
+ function recordCorruptNote(sid, note) {
231
+ var rec = poolHealth[sid] || (poolHealth[sid] = {})
232
+ rec.corruptNote = note
233
+ var text = note.salvage
234
+ ? '看板数据文件腐坏,已自动抢救保留 ' + note.kept + ' 张卡(丢弃撕裂残片),原始文件留档 ' + note.file + ',可联系恢复'
235
+ : '看板数据文件腐坏已隔离,历史在 ' + note.file + ',可联系恢复'
236
+ // 晚绑定直读 deps.notifyBoardCorrupt(index.mjs 在 notify 创建后才收口,创建时取值为 undefined)
237
+ var nb = deps.notifyBoardCorrupt
238
+ if (typeof nb === 'function') {
239
+ try { nb(sid, text) } catch (e) { console.error('[task-board] 腐坏通知失败:', String(e)) }
153
240
  }
154
241
  }
155
242
  // 瞬时读失败/坏文件隔离后的空板必须禁止回写:否则一个「读不到」的瞬间就会把 106 张卡覆成空板。
156
243
  // __noPersist 用 non-enumerable 挂载——即使将来有路径漏判把它写出去,JSON.stringify 也会跳过该字段。
157
244
  function seedNoPersist(sid) { var d = seed(sid, sessionCwd(sid)); try { Object.defineProperty(d, '__noPersist', { value: true, enumerable: false }) } catch (_) {} return d }
158
- // 原子写盘:先写临时文件再 rename——强杀若发生在写盘中途,磁盘上最多留个 .tmp 残件,
245
+ // 原子写盘:写 tmp → fsync → rename 覆盖目标——强杀若发生在写盘中途,磁盘上最多留个 .tmp 残件,
159
246
  // 看板本体永远不会是截断的半个 JSON(此前非原子直写,kill 中写 = 看板被 seed 清空)
247
+ // fsync 保证 tmp 内容真正落盘后再 rename(rename 在同分区是原子的)。
160
248
  // Windows 特有问题:rename 目标被并发读句柄/Defender/索引器短暂占用时抛 EPERM/EBUSY
161
249
  // (E2E 实测:GUI 3s 轮询 + 脚本 10s 轮询下偶发,publish 写入整个丢失)。
162
250
  // 对这类瞬时占用做有限退避重试;其他错误(只读/不存在目录等)直接抛。
251
+ // 重试耗尽仍失败 → 降级「先 unlink 目标再 rename」并记录(Windows rename-over-existing 实测
252
+ // node v24 win32 直接覆盖 OK,仅被占时才需 unlink 兜底——绝不丢整板)。
253
+ async function writeTmpFsync(tmp, content) {
254
+ var fh = await fsNode.promises.open(tmp, 'w')
255
+ try { await fh.writeFile(content, 'utf8'); await fh.sync() }
256
+ finally { await fh.close().catch(function () {}) }
257
+ }
163
258
  async function wt(sid, d) {
164
- var c = JSON.stringify(d); var p = boardPath(sid); var tmp = p + '.tmp'
165
- var lastErr = null
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'
166
264
  for (var attempt = 0; attempt < 6; attempt++) {
167
- try { await fsNode.promises.writeFile(tmp, c, 'utf8'); await fsNode.promises.rename(tmp, p); return }
265
+ try { await writeTmpFsync(tmp, c); await fsNode.promises.rename(tmp, p); return }
168
266
  catch (e) {
169
- lastErr = e
170
267
  var code = e && e.code
171
268
  if (code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY' || code === 'EACCES') {
172
269
  await new Promise(function (r) { setTimeout(r, 60 * (attempt + 1)) })
173
270
  continue
174
271
  }
272
+ await unlinkRetry(tmp, 2) // 非瞬时错误:清掉自己的 tmp 残件再抛(不堆积)
175
273
  console.error('[task-board] write:', String(e)); throw e
176
274
  }
177
275
  }
178
- console.error('[task-board] write: retry exhausted (6 次) ->', String(lastErr)); throw lastErr
276
+ // 退避重试耗尽(顽固瞬时占用)→ 降级「先 unlink 目标再 rename」并记录;最后一步失败则抛(不吞)
277
+ console.error('[task-board] write: rename 直覆失败(重试耗尽),降级 unlink+rename: ' + p)
278
+ try { await writeTmpFsync(tmp, c) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
279
+ await unlinkRetry(p, 6)
280
+ try { await fsNode.promises.rename(tmp, p) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
179
281
  }
180
282
  // 每会话一条 promise 链,串行化所有 读-改-写,消除并发写竞争
181
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 }
@@ -207,7 +309,7 @@ export function createStore(ctx, state, deps) {
207
309
  }
208
310
  if (tm) tm.timeout(50).then(go, go); else Promise.resolve().then(go)
209
311
  }
210
- function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
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 }) }
211
313
 
212
314
  return { rt: rt, wt: wt, kickCycle: kickCycle, mutateLocked: mutateLocked }
213
315
  }