dsh-agent-board 1.7.5 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/rpc.mjs CHANGED
@@ -8,12 +8,12 @@
8
8
  import * as core from './core.mjs'
9
9
  import path from 'node:path'
10
10
  import fsNode from 'node:fs'
11
- import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary, readMainWindowUsage, buildScoreboard, modelPerfHint } from './usage.mjs'
11
+ import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary, readMainWindowUsage, buildScoreboard, modelPerfHint, qualityChangeHints } from './usage.mjs'
12
12
  import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLesson } from './policy.mjs'
13
13
  import { makeMsg } from './notify.mjs'
14
14
  import { computeHealthHints, 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 } = core
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
17
17
 
18
18
  function defineTool(options) {
19
19
  var userExecute = options.execute
@@ -43,6 +43,7 @@ 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
47
48
  // 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
48
49
  // Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
@@ -59,7 +60,36 @@ export function createRpc(ctx, state, deps) {
59
60
  // (runsFor(sid)[taskId] 存在且 !settled 即活跃)。未注入时按「有活跃 run」兜底——
60
61
  // 保守保持旧门禁行为(in-progress 一律拒归档),绝不因缺依赖误放走在跑的任务。
61
62
  var hasActiveRun = typeof deps.hasActiveRun === 'function' ? deps.hasActiveRun : function () { return true }
63
+ // ===== 活跃 run 判定(验收时序三洞 ①②,用户 n-mv0ebretgv1o)=====
64
+ // 口径:内存活跃表(runsFor)是「仍活着」的权威信号(未 settled);卡面 runs 留档(outcome 仍
65
+ // running 且无 endedAt、且 id 匹配当前认领位 claimedBy/verifierRun)是持久化兜底(内存表丢失/重启后)。
66
+ // 只匹配「当前认领位」:手动终止/重派会把认领位换新,旧 run 的 running 留档不再被误判为活跃。
67
+ // 返回 { role, id } 或 null。role 归一 verifier/worker(hook run 走 verifierRun 位,但归档/验收
68
+ // 竞态只关心 worker/verifier 两族;未知角色按 worker 兜底文案)。
69
+ function activeRunOf(sid, t) {
70
+ var rec = runsFor(sid)[t.id]
71
+ if (rec && !rec.settled) return { role: rec.role === 'verifier' ? 'verifier' : 'worker', id: String(rec.id) }
72
+ var trs = Array.isArray(t.runs) ? t.runs : []
73
+ function liveEntry(runId, role) {
74
+ if (!runId || runId === 'spawn-pending') return null
75
+ for (var i = trs.length - 1; i >= 0; i--) {
76
+ var r = trs[i]
77
+ if (r && r.role === role && String(r.id) === String(runId) && (!r.outcome || r.outcome === 'running') && !r.endedAt) return r
78
+ }
79
+ return null
80
+ }
81
+ if (liveEntry(t.verifierRun, 'verifier')) return { role: 'verifier', id: String(t.verifierRun) }
82
+ if (liveEntry(t.claimedBy, 'worker')) return { role: 'worker', id: String(t.claimedBy) }
83
+ return null
84
+ }
85
+ // 迟到驳回 healthHint 落点(内存心跳,sticky——host 重启才清):board_verdict 迟到驳回分支写
86
+ function markLateRejectHealth(sid, taskId) {
87
+ if (!state.poolHealth) state.poolHealth = {}
88
+ var ph = state.poolHealth[sid] || (state.poolHealth[sid] = { bornAt: Date.now() })
89
+ ph.lateRejectNote = { taskId: taskId, at: Date.now() }
90
+ }
62
91
  // archive 门禁(archive-task RPC 与 task_archive 工具同一口径,返回错误串或 null 放行):
92
+ // 任一 run(含 verifier)仍活跃(outcome running 且无 endedAt / 内存表未 settled)→ 拒绝(先查,宁拦勿漏)
63
93
  // resolved / cancelled → 放行(既有行为)
64
94
  // in-progress 且无活跃 run → 放行(parentKick 僵尸态出清:direct epic 被
65
95
  // parentKickOnDispatch 推进到 in-progress 后子任务已归档、无 run、claimedBy=null,
@@ -67,6 +97,8 @@ export function createRpc(ctx, state, deps) {
67
97
  // 只能 resetToPending→delete 三段舞)
68
98
  // in-progress 有活跃 run / 其余状态 → 拒绝
69
99
  function archiveErr(sid, t) {
100
+ var act = activeRunOf(sid, t)
101
+ if (act) return '有活跃 run(' + (act.role === 'verifier' ? 'verifier ' : 'worker ') + act.id + ' 仍在跑),等它落定或先 terminate'
70
102
  if (t.status === 'resolved' || t.status === 'cancelled') return null
71
103
  if (t.status === 'in-progress' && !hasActiveRun(sid, t.id)) return null
72
104
  return 'cannot archive'
@@ -93,6 +125,29 @@ export function createRpc(ctx, state, deps) {
93
125
  var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
94
126
  return function (p) { try { return fsNode.existsSync(path.isAbsolute(p) ? p : (cwd ? path.join(cwd, p) : p)) } catch (_) { return false } }
95
127
  }
128
+ // 起草 lint 的 IO 注入(双仓库歧义读盘):root=看板工作区(ownerCwd 优先,回退 sessionCwd),
129
+ // subdirs=root 下直接子目录名(readdirSync + isDirectory 过滤),dirExists=判目录存在。
130
+ // 读不到/异常一律按空处理(lint 是软提示,宁缺勿误报,绝不能因 IO 抛异常阻断建卡)。
131
+ function draftLintIo(sid, ownerCwd) {
132
+ var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
133
+ var root = String(ownerCwd || cwd || '')
134
+ var subdirs = []
135
+ if (root) {
136
+ try {
137
+ subdirs = fsNode.readdirSync(root, { withFileTypes: true })
138
+ .filter(function (e) { return e && e.isDirectory() })
139
+ .map(function (e) { return e.name })
140
+ } catch (_) { subdirs = [] }
141
+ }
142
+ return {
143
+ root: root,
144
+ subdirs: subdirs,
145
+ dirExists: function (p) {
146
+ if (!p) return false
147
+ try { return fsNode.existsSync(p) && fsNode.statSync(p).isDirectory() } catch (_) { return false }
148
+ }
149
+ }
150
+ }
96
151
  // 预检有缺失才 pushSysNote 汇总给主窗口;全部有材料不打扰(响应体同样不挂字段)。锁外调用,不拖长写锁。
97
152
  function afterPublishPrecheck(sid, taskId, result) {
98
153
  var pre = result && result.ok && result.epicPrecheck
@@ -126,18 +181,38 @@ export function createRpc(ctx, state, deps) {
126
181
  // 门禁,旧守卫 `t.claimedBy !== actor` 对 null 零容忍 = 谁都解不了,手动兜底被堵死;
127
182
  // claimedBy 非空且非本人时照旧拒(既有守卫语义不变)。
128
183
  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) }) } }))
129
- 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 }) } }))
184
+ 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 }) } }))
130
185
  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') }) } }))
131
- ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时以文件清单(路径:L行号 — 一句用途)随首条 prompt 注入,内容由子代理按需 read 自取' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; delete t.retryCount; delete t.stuckSince; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
132
- ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时以「文件清单」(每行「路径:L起-L止 — 一句用途」)随首条 prompt 一次性注入,只给路径与行号、不含内容本体(子代理用 read 工具按行号范围按需自取:执行时盘面更新鲜,也免了大文件正文撑爆 prompt 与随快照每轮重发),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,全文随首条 prompt 一次性注入(≤8000 字符)。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 contextFiles、结论思路放进 contextNotes——实测可让 Worker 省去 10~15 分钟从零 grep 定位的时间,且方向感天壤之别(无调研的 Worker 只能靠标题猜需求、极易跑偏)。调研门禁:pipeline=full/work 且 touches 非空而未带调研上下文时,返回将附 warning 提示。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务持有文件锁(in-progress/verifying 持有,状态流转到 resolved/cancelled/archived 即释放——锁只护正在写的阶段,验收后-提交前的窗口期由主窗口提交纪律 + 史诗 post-hook 承接)。' + TASK_SIZE_CONTRACT, parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径;可写「路径:L1-L2 — 一句用途」),派发时以文件清单随首条 prompt 注入(只给路径行号,内容由子代理按需 read 自取)' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),全文随首条 prompt 一次性注入' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (args.hooks !== undefined) { var __hn = normalizeHooks(args.hooks); if (__hn.error) return { ok: false, error: __hn.error }; if (__hn.hooks && Object.keys(__hn.hooks).length) t.hooks = __hn.hooks } if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); /* 调研门禁 warning 族(与 create-task RPC 同口径,core 纯函数):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建 */ var __out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var __warns = createTaskWarnings(t); attachContextSuggestions(__out, __warns, t.touches, existsInSession(sid)); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
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 } }))
187
+ 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 }) } }))
133
188
 
134
189
  // ===== RPC =====
135
190
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
136
191
  // 客户端 3s 轮询不再触发池计算/写盘(之前每轮询一次就 poolCycle+写盘一次,切会话时多会话轮询挤在文件锁上)
137
- // isRoot 现算不落盘:生成开始/结束瞬间 agents 树重建,roots() 存在瞬态窗口返回不含本 sid(假 false)。
138
- // host 不改行为(保持"现算真相"),防抖在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次
139
- // false(3s 心跳≈9s)才收抽屉;从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
140
- 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 }
192
+ // isRoot 持久语义(banner 入口瞬态/持久消失修复):根会话=无父(持久 parentSession 缺省),不再读活跃 roots() 集。
193
+ // 旧实现 roots() 只含活体会话——休眠根会话(有板有卡)恒 false,且生成开始/结束 agents 树重建有瞬态窗口
194
+ // 返回不含本 sid(假 false),入口分钟级消失。改读持久 parentSession(见 isRootPersistent):
195
+ // 主源 sessionPersistence.stat(sid)(持久表,休眠会话也读得到)→ header.parentSession 缺省 = 根;
196
+ // 老宿主/测试桩无 sessionPersistence → 回退活跃 agent 的 session.header.parentSession,再回退旧 roots() 成员判定。
197
+ // 防抖仍在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次 false(3s 心跳≈9s)才收抽屉;
198
+ // 从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
199
+ async function isRootPersistent(sid) {
200
+ var sp = ctx.get && ctx.get('sessionPersistence')
201
+ if (sp && typeof sp.stat === 'function') {
202
+ try { var snap = await sp.stat(sid); if (snap && snap.header) return !snap.header.parentSession } catch (_) {}
203
+ }
204
+ var ag = ctx.agents
205
+ if (ag) {
206
+ var live = typeof ag.get === 'function' ? ag.get(sid) : undefined
207
+ if (live && live.session && live.session.header && live.session.header.parentSession) return false
208
+ var roots = ag.roots()
209
+ var rids = []
210
+ for (var i = 0; i < roots.length; i++) rids.push(String(roots[i].id))
211
+ return rids.indexOf(sid) >= 0
212
+ }
213
+ return true
214
+ }
215
+ handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; d.isRoot = await isRootPersistent(sid)
141
216
  // poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
142
217
  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] }) }
143
218
  // 学习飞轮 v1:把 feedbackEnabled 显式放进返回体(normalizeBoard 已按老看板补默认 true),
@@ -193,11 +268,23 @@ export function createRpc(ctx, state, deps) {
193
268
  // 合并后 runs≥10 且一次通过率≥90% 且有效均值较其他合格模型严格低 >30% → 黄条建议设默认 workerModel;
194
269
  // 无足够数据返回 null 不亮)。拼在尾部——运行时事故 > 架构信号 > 模型建议(建议性 hint 最不紧急)。
195
270
  var __mph = modelPerfHint(d.usageSummary.scoreboard)
196
- d.healthHints = __rh.hints.concat(computeHealthHints(d.tasks)).concat(__mph ? [__mph] : [])
271
+ // 质量异动告警(异常驱动审视②):scoreboard.trends 环比(一次通过率跌 / 超时率涨 / 驳回率涨),
272
+ // 拼在静态架构信号之后、荐模型建议之前——质量事故 > 架构建议 > 模型建议(最不紧急垫底)。
273
+ var __qch = qualityChangeHints(d.usageSummary.scoreboard)
274
+ d.healthHints = __rh.hints.concat(computeHealthHints(d.tasks)).concat(__qch).concat(__mph ? [__mph] : [])
197
275
  // 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, settled, resolved, active, activeTitle } },
198
276
  // 父卡列位置/进度展示的数据源;total 含已归档子任务,settled=resolved|cancelled|archived(resolved 为
199
277
  // 兼容别名同值),因此归档子卡不会让进度分母缩水(task-muupgfot);只有无任何子任务的父卡才不出键
200
278
  d.childStats = aggregateChildStats(d.tasks)
279
+ // ===== 风险队列「待你过目」:reviewHint 现算不落库(异常驱动审视①,task-muzikj1y)=====
280
+ // 四硬信号 + 两软信号(软信号仅质量异动告警激活期间计入,读 __qch 非空)现算后挂到每张卡,
281
+ // get-tasks 透出;唯一新落库字段=reviewedAt(mark-reviewed RPC 落)。挂在 tasksHash 之前——
282
+ // reviewHint + reviewedAt 都驱动列表渲染,须进 hash 序列化口径(否则已阅/软信号联动不重渲染)。
283
+ var __rhints = core.computeReviewHints(d.tasks, { qualityAlertActive: __qch.length > 0 })
284
+ for (var __ri = 0; __ri < (d.tasks || []).length; __ri++) {
285
+ var __rkt = d.tasks[__ri]
286
+ if (__rkt && __rhints[__rkt.id]) __rkt.reviewHint = __rhints[__rkt.id]
287
+ }
201
288
  // 渲染变更检测(反馈 n-mut9rzs2mkhg):tasks 关键字段的稳定 hash(纯函数,口径见 core.tasksHash),
202
289
  // 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify——传输仍全量,省的是渲染
203
290
  d.tasksHash = tasksHash(d.tasks)
@@ -282,8 +369,13 @@ export function createRpc(ctx, state, deps) {
282
369
  })
283
370
  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 } }) })
284
371
  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) }) })
285
- 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 }) })
372
+ 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 }) })
286
373
  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') }) })
374
+ // ===== 已阅落账(异常驱动审视①③):打开详情页即幂等落 reviewedAt(唯一新落库字段)=====
375
+ // 幂等只写一次:已有 reviewedAt 就原样返回、绝不重写(时间戳不漂移、history 不刷屏);
376
+ // 后续重复打开详情页零副作用。reviewedAt 进 tasksHash 序列化口径 → 落账后下一轮 get-tasks hash 变化,
377
+ // 客户端列表徽章/chip 即时消失(配合 kernel.fetchTasks 的 hash 短路渲染节约)。
378
+ 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 } }) })
287
379
  // ===== 任务删除通道(真删,无 undo)=====
288
380
  // 背景:archive-task 只收 resolved/cancelled,草稿/误建卡片此前没有任何下线通道(只能永远挂着)。
289
381
  // 状态门禁(delete-task 与 batch-op delete 共用本函数,保证两条入口语义完全一致):
@@ -320,7 +412,7 @@ export function createRpc(ctx, state, deps) {
320
412
  return { ok: true, deleted: t.id }
321
413
  })
322
414
  })
323
- 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 } }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
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 })
324
416
  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) } }) })
325
417
  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) } }) })
326
418
  // 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
@@ -486,7 +578,11 @@ export function createRpc(ctx, state, deps) {
486
578
  if (!t) return null
487
579
  delete t.stuckSince
488
580
  if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', actor, '手动终止,任务重新排队') }
489
- else if (t.status === 'verifying') { ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
581
+ // 验收时序三洞② 回归修复:终止 verifier 时同步清 verifierRun 认领位(与 in-progress 清 claimedBy
582
+ // 同口径)。否则卡面 verifierRun 仍指向已终止 run 的 running 留档(terminate 刻意不 closeRunHistory),
583
+ // activeRunOf 的卡面兜底(liveEntry 按 verifierRun 匹配)会把已死的 run 误判为活跃 → archive 门禁
584
+ // 永久拒绝,而报错文案推荐的补救「先 terminate」再跑一次也是空转(内存表已摘、认领位不清)= 死结。
585
+ else if (t.status === 'verifying') { t.verifierRun = null; delete t.verifierRunAt; ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
490
586
  return t
491
587
  })
492
588
  return { ok: true, terminated: label }
@@ -594,7 +690,14 @@ export function createRpc(ctx, state, deps) {
594
690
  var result = await mutateLocked(sid, function (d) {
595
691
  var t = d.tasks.find(function (x) { return x.id === args.taskId })
596
692
  if (!t) return { ok: false, error: 'not found' }
597
- if (t.status !== 'verifying') return { ok: false, error: 'not verifying (状态: ' + t.status + ')' }
693
+ if (t.status !== 'verifying') {
694
+ // 迟到落账(验收时序三洞 ③):卡已落定(resolved/archived/cancelled)→ verifier 结论不再静默丢弃
695
+ if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
696
+ var lr = lateVerdictApply(t, args.verdict, args.summary, args.checks, args.userTest, new Date().toISOString(), actor, cfg(d).verifyUserGuide)
697
+ return { ok: true, late: true, lateRejected: lr.lateRejected && lr.wasApproved, task: t }
698
+ }
699
+ return { ok: false, error: 'not verifying (状态: ' + t.status + ')' }
700
+ }
598
701
  delete t.escalation; delete t.verifyRetries // verifier 恢复产出:清人工验收标记
599
702
  t.verification = { verdict: args.verdict, summary: args.summary || '', checks: args.checks || '', at: new Date().toISOString(), by: actor }
600
703
  // 自测指南落账(verifyUserGuide 开关门禁,task-muxyyvg0):normalizeUserTest 归一(tier 三档
@@ -610,16 +713,21 @@ export function createRpc(ctx, state, deps) {
610
713
  if (!approved) pushRejection(t, args.summary, args.checks, t.verification.at, actor)
611
714
  return { ok: true, task: t }
612
715
  })
613
- if (result && result.ok && result.task) {
716
+ if (result && result.ok && result.task && !result.late) {
614
717
  if (result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
615
718
  if (result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
616
719
  }
617
- if (result && result.ok && !approved && result.task) {
720
+ if (result && result.ok && !approved && result.task && !result.late) {
618
721
  // v74 一次性模型:原 Worker 已销毁,驳回任务回 pending 重派新 Worker(完整驳回包已落 t.messages,
619
722
  // 经 buildMessages 全量随 prompt 注入;history 只是审计轨)
620
723
  var bt = result.task
621
724
  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) }
622
725
  }
726
+ // 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知(回执直投 owner,不走开关)+ healthHint
727
+ if (result && result.ok && result.late && result.lateRejected) {
728
+ if (notifyLateReject) notifyLateReject(sid, result.task)
729
+ markLateRejectHealth(sid, result.task.id)
730
+ }
623
731
  return result
624
732
  } }))
625
733
  // 枚举当前网关可用模型(供 Verifier 模型下拉选择):llm.listProviders + listModels
@@ -662,7 +770,7 @@ export function createRpc(ctx, state, deps) {
662
770
  // 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
663
771
  // 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
664
772
  handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'workerContinuable') d.workerContinuable = !!args.value; else if (args.key === 'verifyUserGuide') d.verifyUserGuide = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
665
- handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor = getActorId(); /* hooks 只许主窗口设置(角色门禁) */ if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; return mutateLocked(sid, function (d) { var nHooks = null; if (args.hooks !== undefined) { var hn = normalizeHooks(args.hooks); if (hn.error) return { ok: false, error: hn.error }; nHooks = hn.hooks } if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (nHooks && Object.keys(nHooks).length) t.hooks = nHooks; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ var out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var warns = createTaskWarnings(t); attachContextSuggestions(out, warns, t.touches, existsInSession(sid)); if (warns.length) out.warning = warns.join(';'); return out }) })
773
+ handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor = getActorId(); /* hooks 只许主窗口设置(角色门禁) */ if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; return mutateLocked(sid, function (d) { var nHooks = null; if (args.hooks !== undefined) { var hn = normalizeHooks(args.hooks); if (hn.error) return { ok: false, error: hn.error }; nHooks = hn.hooks } if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (nHooks && Object.keys(nHooks).length) t.hooks = nHooks; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ var out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var warns = createTaskWarnings(t); attachContextSuggestions(out, warns, t.touches, existsInSession(sid)); if (warns.length) out.warning = warns.join(';'); return out }) })
666
774
  handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
667
775
  // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
668
776
  // delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
@@ -689,7 +797,9 @@ export function createRpc(ctx, state, deps) {
689
797
  console.error('[task-board] batch delete: ' + id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
690
798
  done++
691
799
  } else if (args.op === 'archive') {
692
- if (t.status !== 'resolved' && t.status !== 'cancelled') { skipped.push(id); return }
800
+ var __act = activeRunOf(sid, t)
801
+ if (__act) { skip(id, '有活跃 run(' + (__act.role === 'verifier' ? 'verifier ' : 'worker ') + __act.id + ' 仍在跑),等它落定或先 terminate'); return }
802
+ if (t.status !== 'resolved' && t.status !== 'cancelled') { skip(id, 'cannot archive'); return }
693
803
  var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'batch archive'); done++
694
804
  archived.push(t)
695
805
  } else if (args.op === 'set-priority') {
@@ -705,7 +815,7 @@ export function createRpc(ctx, state, deps) {
705
815
  // 刻意不级联归档子卡:批量语义只动显式选中的 id(与单卡 archiveApply 的级联不同,保持老行为)。
706
816
  var parentsClosed = 0
707
817
  for (var ai = 0; ai < archived.length; ai++) { if (maybeAutoCloseParent(d, archived[ai])) parentsClosed++ }
708
- // reasons 只在 delete op 下有内容(其他 op 的跳过原因沿用"未命中门禁"的老行为),
818
+ // reasons 在 delete(deleteGate 门禁原因)与 archive(cannot archive)op 下有内容;
709
819
  // 老客户端只读 skipped.length,多一个可选字段无感。
710
820
  var out = { ok: true, done: done, skipped: skipped, reasons: reasons }
711
821
  if (parentsClosed) out.parentsClosed = parentsClosed
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,24 @@ import path from 'node:path'
8
9
  import fsNode from 'node:fs'
9
10
  const { seed, normalizeBoard, vt, boardHome } = core
10
11
 
12
+ // 腐坏看板自动抢救(P0 根修 task-mv0bl9vg ③):从撕裂/夹残片的 JSON 原文里截取**第一个完整 JSON 值**。
13
+ // 手法 = JSON.parse 报错 position 截断法(本次事故人工恢复就是这招):整串 parse 失败时,
14
+ // V8 报错信息含 "position N"(实测 node v24:'{"a":1}{"b":2}' 报 position 18,即第二个值起点),
15
+ // 截到 N 再 parse 即得第一个完整值;无 position(如单对象中部截断 "Unexpected end of JSON input")或
16
+ // 截断后仍 parse 失败 → 返回 { ok:false },交由调用方隔离空板。
17
+ function trySalvage(raw) {
18
+ if (typeof raw !== 'string' || !raw) return { ok: false }
19
+ try { var d = JSON.parse(raw); return d && typeof d === 'object' ? { ok: true, board: d } : { ok: false } }
20
+ catch (e) {
21
+ var m = /position (\d+)/.exec(String(e && e.message))
22
+ if (!m) return { ok: false }
23
+ var pos = parseInt(m[1], 10)
24
+ if (!(pos > 0)) return { ok: false }
25
+ try { var v = JSON.parse(raw.slice(0, pos)); return v && typeof v === 'object' ? { ok: true, board: v } : { ok: false } }
26
+ catch (_) { return { ok: false } }
27
+ }
28
+ }
29
+
11
30
  export function createStore(ctx, state, deps) {
12
31
  var sessionCwd = deps.sessionCwd
13
32
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
@@ -16,6 +35,7 @@ export function createStore(ctx, state, deps) {
16
35
  var epicSplitCache = state.epicSplitCache
17
36
  var fileLocks = state.fileLocks
18
37
  var cyclePending = state.cyclePending
38
+ var poolHealth = state.poolHealth || (state.poolHealth = {}) // 腐坏隔离/抢救 hint 落这里(与 runtime health 同表)
19
39
 
20
40
  // 看板文件用「家目录绝对路径 + node:fs」直读写——不再走 fs 服务的相对路径解析
21
41
  // (fs 服务的相对路径解析根 = 进程启动 cwd,cwd 一变所有看板静默读成空板;
@@ -146,27 +166,81 @@ export function createStore(ctx, state, deps) {
146
166
  }
147
167
  return seed(sid, sessionCwd(sid))
148
168
  } 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 () {})
152
- return seedNoPersist(sid)
169
+ // JSON 截断/损坏(如强杀打断写盘、中部夹杂另一版本残片)→ 抢救 + 隔离 + 可见化(P0 根修)
170
+ return recoverCorrupt(sid, r)
171
+ }
172
+ }
173
+ // ===== 腐坏看板:自动 salvage + 隔离可见化(task-mv0bl9vg ②③)=====
174
+ // 三件套:① 隔离前先 trySalvage 截断抢救(成功则以抢救出的完整 JSON 为底继续);
175
+ // ② 原始坏文件一律 .corrupt-<ts> 留档(抢救成功=复制留档+写回覆盖,抢救失败=改名隔离;撕裂尾部都不丢);
176
+ // ③ 追加 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)
209
+ }
210
+ // 腐坏/抢救 hint 落 poolHealth(get-tasks 现算透出 err 级 hint)+ notify 通知 owner
211
+ function recordCorruptNote(sid, note) {
212
+ var rec = poolHealth[sid] || (poolHealth[sid] = {})
213
+ rec.corruptNote = note
214
+ var text = note.salvage
215
+ ? '看板数据文件腐坏,已自动抢救保留 ' + note.kept + ' 张卡(丢弃撕裂残片),原始文件留档 ' + note.file + ',可联系恢复'
216
+ : '看板数据文件腐坏已隔离,历史在 ' + note.file + ',可联系恢复'
217
+ // 晚绑定直读 deps.notifyBoardCorrupt(index.mjs 在 notify 创建后才收口,创建时取值为 undefined)
218
+ var nb = deps.notifyBoardCorrupt
219
+ if (typeof nb === 'function') {
220
+ try { nb(sid, text) } catch (e) { console.error('[task-board] 腐坏通知失败:', String(e)) }
153
221
  }
154
222
  }
155
223
  // 瞬时读失败/坏文件隔离后的空板必须禁止回写:否则一个「读不到」的瞬间就会把 106 张卡覆成空板。
156
224
  // __noPersist 用 non-enumerable 挂载——即使将来有路径漏判把它写出去,JSON.stringify 也会跳过该字段。
157
225
  function seedNoPersist(sid) { var d = seed(sid, sessionCwd(sid)); try { Object.defineProperty(d, '__noPersist', { value: true, enumerable: false }) } catch (_) {} return d }
158
- // 原子写盘:先写临时文件再 rename——强杀若发生在写盘中途,磁盘上最多留个 .tmp 残件,
226
+ // 原子写盘:写 tmp → fsync → rename 覆盖目标——强杀若发生在写盘中途,磁盘上最多留个 .tmp 残件,
159
227
  // 看板本体永远不会是截断的半个 JSON(此前非原子直写,kill 中写 = 看板被 seed 清空)
228
+ // fsync 保证 tmp 内容真正落盘后再 rename(rename 在同分区是原子的)。
160
229
  // Windows 特有问题:rename 目标被并发读句柄/Defender/索引器短暂占用时抛 EPERM/EBUSY
161
230
  // (E2E 实测:GUI 3s 轮询 + 脚本 10s 轮询下偶发,publish 写入整个丢失)。
162
231
  // 对这类瞬时占用做有限退避重试;其他错误(只读/不存在目录等)直接抛。
232
+ // 重试耗尽仍失败 → 降级「先 unlink 目标再 rename」并记录(Windows rename-over-existing 实测
233
+ // node v24 win32 直接覆盖 OK,仅被占时才需 unlink 兜底——绝不丢整板)。
234
+ async function writeTmpFsync(tmp, content) {
235
+ var fh = await fsNode.promises.open(tmp, 'w')
236
+ try { await fh.writeFile(content, 'utf8'); await fh.sync() }
237
+ finally { await fh.close().catch(function () {}) }
238
+ }
163
239
  async function wt(sid, d) {
164
240
  var c = JSON.stringify(d); var p = boardPath(sid); var tmp = p + '.tmp'
165
- var lastErr = null
166
241
  for (var attempt = 0; attempt < 6; attempt++) {
167
- try { await fsNode.promises.writeFile(tmp, c, 'utf8'); await fsNode.promises.rename(tmp, p); return }
242
+ try { await writeTmpFsync(tmp, c); await fsNode.promises.rename(tmp, p); return }
168
243
  catch (e) {
169
- lastErr = e
170
244
  var code = e && e.code
171
245
  if (code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY' || code === 'EACCES') {
172
246
  await new Promise(function (r) { setTimeout(r, 60 * (attempt + 1)) })
@@ -175,7 +249,11 @@ export function createStore(ctx, state, deps) {
175
249
  console.error('[task-board] write:', String(e)); throw e
176
250
  }
177
251
  }
178
- console.error('[task-board] write: retry exhausted (6 次) ->', String(lastErr)); throw lastErr
252
+ // 退避重试耗尽(顽固瞬时占用)→ 降级「先 unlink 目标再 rename」并记录;最后一步失败则抛(不吞)
253
+ 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 }
255
+ await unlinkRetry(p, 6)
256
+ await fsNode.promises.rename(tmp, p)
179
257
  }
180
258
  // 每会话一条 promise 链,串行化所有 读-改-写,消除并发写竞争
181
259
  function withLock(sid, fn) { var prev = fileLocks[sid] || Promise.resolve(); var p = prev.then(function () { return fn() }); fileLocks[sid] = p.catch(function () {}); return p }