dsh-agent-board 1.7.4 → 1.7.6

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/core.mjs CHANGED
@@ -131,10 +131,15 @@ export function classifyPipeline(t) {
131
131
  // turn 结算靠 host 事件 agent/status running→idle),false 逐字回退旧的 subagents.start() 一次性路径
132
132
  // (run.result 结算)。Verifier/hook run 一律照旧 one-shot,不看这个键(它们无续跑语义)。
133
133
  // 消费点只有派发引擎(spawn 时读一次 cfg 快照),所以不需要 feedbackEnabled/epicSplit 那样的热路径缓存。
134
+ // Verifier 自测指南开关 verifyUserGuide(设置区「通知」小节旁「验收」小节,缺省 true):
135
+ // false → ① Verifier prompt 不拼 USER_GUIDE_CONTRACT 指南段(省 token);
136
+ // ② 验收落账(settleVerifier/board_verdict)不挂 t.verification.userTest;
137
+ // ③ client 详情页自测指南块与报告「本版自测清单」段整块不渲染。
138
+ // 消费点同为 spawn/结算时读 cfg 快照,无需热路径缓存。
134
139
  export function cfg(d) {
135
140
  var soft = Math.max(1, Math.min(480, d.softTimeoutMin || 30))
136
141
  var hard = Math.max(soft, Math.min(1440, d.hardTimeoutMin || 120))
137
- return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false, workerContinuable: d.workerContinuable !== false }
142
+ return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false, workerContinuable: d.workerContinuable !== false, verifyUserGuide: d.verifyUserGuide !== false }
138
143
  }
139
144
 
140
145
  // ===== 看板数据目录(跨重启继承用)=====
@@ -151,7 +156,7 @@ export function boardHome() { return path.join(boardDirName(), '.dsh') }
151
156
  // ownerCwd(跨重启继承):创建该看板的会话工作区路径,继承判定全靠它——取不到就省略字段
152
157
  // (绝不落空串,否则「路径读不到的多个会话」会被误判成同一工作区)。
153
158
  export function seed(sid, ownerCwd) {
154
- var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
159
+ var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
155
160
  if (typeof ownerCwd === 'string' && ownerCwd) d.ownerCwd = ownerCwd
156
161
  return d
157
162
  }
@@ -163,6 +168,7 @@ export function seed(sid, ownerCwd) {
163
168
  // epicSplit(史诗拆分总开关)同法:老看板没有该字段(或脏值)→ 补 true,缺省开 = 引导照旧。
164
169
  // workerContinuable(Worker 可续跑开关)同法:老看板没有该字段(或脏值)→ 补 true,
165
170
  // 即老看板读进来就按新行为(可续跑 Worker)派发;显式落 false 才是逐字回退旧一次性路径。
171
+ // verifyUserGuide(Verifier 自测指南开关)同法:老看板没有该字段(或脏值)→ 补 true,缺省开 = 指南照常。
166
172
  export function normalizeBoard(d) {
167
173
  if (d && typeof d === 'object') {
168
174
  if (!d.poolStatus || typeof d.poolStatus !== 'object' || !Array.isArray(d.poolStatus.workers) || !Array.isArray(d.poolStatus.verifiers)) d.poolStatus = { workers: [], verifiers: [] }
@@ -171,6 +177,7 @@ export function normalizeBoard(d) {
171
177
  if (typeof d.notifyDone !== 'boolean') d.notifyDone = true
172
178
  if (typeof d.epicSplit !== 'boolean') d.epicSplit = true
173
179
  if (typeof d.workerContinuable !== 'boolean') d.workerContinuable = true
180
+ if (typeof d.verifyUserGuide !== 'boolean') d.verifyUserGuide = true
174
181
  if (Array.isArray(d.tasks)) {
175
182
  for (var i = 0; i < d.tasks.length; i++) {
176
183
  var t = d.tasks[i]
@@ -184,11 +191,17 @@ export function normalizeBoard(d) {
184
191
  // ===== 状态流转 =====
185
192
  export function claimCheck(d, t, sid) { if (CLAIMABLE.indexOf(t.status) < 0) return 'cannot claim in ' + t.status; if (t.claimedBy && t.claimedBy !== sid && t.status === 'in-progress') return 'claimed by ' + t.claimedBy; if (d.boardMode === 'manual' || t.assignMode === 'manual') { if (t.assignee && t.assignee !== sid) return 'assigned to ' + t.assignee }; if (isb(t)) { var p = gpt(t, d.tasks); if (!p) return 'parent not found'; if (p.status !== 'in-progress' && p.status !== 'verifying') return 'parent not in-progress' }; var mc = d.tasks.filter(function (x) { return x.claimedBy === sid && (x.status === 'in-progress' || x.status === 'verifying') && !isb(x) }); if (!isb(t) && mc.length >= MAX_CLAIMED) return 'max ' + MAX_CLAIMED + ' active'; return null }
186
193
  export function claimApply(d, t, sid, note) { var ps = t.status; t.status = 'in-progress'; t.claimedBy = sid; t.claimedAt = new Date().toISOString(); ah(t, ps, 'in-progress', sid, note) }
187
- // 子任务「已了结」终态口径:resolved(已完成/已验收)+ cancelled(人主动放弃该子任务范围)。
194
+ // 子任务「已了结」终态口径:resolved(已完成/已验收)+ cancelled(人主动放弃该子任务范围)
195
+ // + archived(归档=人的显式了结,与 childStats 进度口径同源)。
188
196
  // 为什么 cancelled 也算:cancelled 是人的显式决定,该子任务范围已关闭;若不算,一张被取消的
189
197
  // 子任务会把 epic 永久钉在 in-progress(手动取消的卡反而制造死卡)。验收时人仍可在 epic 上驳回。
190
- // 注:archived 不算——归档是「收尾/清理」动作,不应反向推动父卡流转(父卡归档时会级联归档子任务)。
191
- export function isChildSettled(t) { return !!t && (t.status === 'resolved' || t.status === 'cancelled') }
198
+ // 为什么 archived 也算(反馈 n-muw706h1uymy 实证修):旧口径「归档不算」会在「resolve 一张归档
199
+ // 一张」的交错序列下制造死卡——末子卡 resolve 时兄卡已归档,终态集永远凑不齐,epic 永久卡
200
+ // in-progress(真实盘面:childStats 3/3 settled,父卡停在 in-progress)。归档动作本身即人的显式
201
+ // 了结(archiveErr 门禁只放行 resolved/cancelled/无活跃 run 的僵尸卡),计入终态集语义成立。
202
+ // 安全性:父卡归档级联子卡时父卡已是 archived(非 in-progress),maybeAutoCloseParent 第一道
203
+ // 状态闸门即拦,级联不会反向误推动父卡。
204
+ export function isChildSettled(t) { return !!t && (t.status === 'resolved' || t.status === 'cancelled' || t.status === 'archived') }
192
205
  // 史诗父卡自动收口(共享 helper,唯一判定口径):父卡存在、父卡 in-progress、且全部子任务 ∈ 终态完成集
193
206
  // → 父卡转 verifying(交人验收)。幂等:父卡已 verifying/resolved/archived 一律返回 null,重复调用无副作用。
194
207
  export function maybeAutoCloseParent(d, childTask) {
@@ -226,6 +239,27 @@ export function maybeAutoCloseParent(d, childTask) {
226
239
  export function checkParentAuto(d, t) { return maybeAutoCloseParent(d, t) }
227
240
  export function resolveApply(d, t, sid, status, resolution, note) { var ps = t.status; if (status === 'verifying' && t.pipeline && t.pipeline !== 'full') { status = 'resolved' } t.status = status; t.resolution = resolution || null; t.resolvedAt = new Date().toISOString(); ah(t, ps, status, sid, note); var r = { ok: true, task: t }; var p = maybeAutoCloseParent(d, t); if (p) { r.parentUpdated = true }; return r }
228
241
  export function verifyApply(d, t, sid, verdict, comment) { var ps = t.status; if (verdict === 'approved') { t.status = 'resolved'; t.verifiedAt = new Date().toISOString(); t.verifiedBy = sid; delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, ps, 'resolved', sid, 'approved' + (comment ? ': ' + comment : '')) } else { t.status = 'in-progress'; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'in-progress', sid, 'rejected' + (comment ? ': ' + comment : '')) }; var r = { ok: true, task: t }; if (verdict === 'approved' && isb(t)) { var p = checkParentAuto(d, t); if (p) { r.parentUpdated = true } }; return r }
242
+ // 归档落定(task_archive 工具与 archive-task RPC 共享,单一行为口径;门禁 archiveErr 留在
243
+ // rpc.mjs——活性判定依赖 index.mjs 注入的 hasActiveRun,core 保持纯函数):置 archived +
244
+ // 级联归档子卡 + 触发父卡自动收口。
245
+ // 父卡检查是本路径的必挂钩子(反馈 n-muw706h1uymy 根因②):旧实现归档后不查父卡,
246
+ // 「末子卡归档」这一下永远补不上收口——isChildSettled 计入 archived 后,本钩子是交错
247
+ // resolve+archive 序列与僵尸子卡直接出清(v1.7.1 放行面)两条链路的最后保险。
248
+ // 父卡带 post hook 时 maybeAutoCloseParent 内部照旧只挂闸门(pending+running),不直接转
249
+ // verifying——hooks 语义逐字不变。note 由调用方给(工具 'archived' / RPC 'manual archive')。
250
+ export function archiveApply(d, t, actor, note) {
251
+ var ps = t.status
252
+ t.status = 'archived'
253
+ t.archivedAt = new Date().toISOString()
254
+ ah(t, ps, 'archived', actor, note || 'archived')
255
+ var ca = 0
256
+ gsb(t.id, d.tasks).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } })
257
+ var r = { ok: true, task: t }
258
+ if (ca) r.childrenArchived = ca
259
+ var p = maybeAutoCloseParent(d, t) // 归档路径同样触发父卡自动收口(isChildSettled 已计 archived)
260
+ if (p) r.parentUpdated = true
261
+ return r
262
+ }
229
263
 
230
264
  // ===== 史诗 hooks=agent run(宿主生命周期接线)=====
231
265
  // 定位:hook 点 = 一次**真实 agent 运行**(不是声明式命令、不走 shell),挂在 epic 卡上、
@@ -381,8 +415,9 @@ export function parentKickOnDispatch(d, t) { if (!isb(t)) return null; var p = g
381
415
  // 正是用户报的「有子任务完成后史诗进度从 0/10 变 0/9」。进度只增不减是这条口径的红线。
382
416
  // 近似说明:v1.7.1 起 archive-task 放行「无活跃 run 的 in-progress 僵尸卡」,故 archived 不再严格
383
417
  // 等于「曾经完成」;但归档动作本身仍是人的显式了结,进度语义上计入是对的(僵尸出清场景里父卡
384
- // 通常随之归档,不影响在板卡片的展示)。注意与 isChildSettled(父卡自动收口口径,archived 不算)
385
- // 的区别:那条管「是否推动父卡流转」,本条只管「进度分母/分子的展示口径」,两者刻意不合并。
418
+ // 通常随之归档,不影响在板卡片的展示)。与 isChildSettled(父卡自动收口口径)现已同口径:
419
+ // archived 同样计入终态集(反馈 n-muw706h1uymy 修复——归档不入终态会让交错归档的 epic 永久
420
+ // 卡死);进度展示与收口流转共用同一「了结」语义,展示 n/n settled 时父卡必已具备收口条件。
386
421
  export function aggregateChildStats(tasks) {
387
422
  var out = {}
388
423
  var list = Array.isArray(tasks) ? tasks : []
@@ -409,6 +444,7 @@ export function parseSections(text) {
409
444
  var title = matches[i].title
410
445
  if (/开发描述/.test(title)) out.summary = body
411
446
  else if (/改动/.test(title)) out.changes = body
447
+ else if (/自测指南|用户自测/.test(title)) { var ut = parseUserTest(body); if (ut) out.userTest = ut } // 须在「自测」前:自测指南 ≠ 自测情况
412
448
  else if (/自测/.test(title)) out.selfTest = body
413
449
  else if (/diff|变更概要/i.test(title)) out.diff = body
414
450
  else if (/测试概要|验证概要|审查概要/.test(title)) out.verifySummary = body
@@ -416,6 +452,51 @@ export function parseSections(text) {
416
452
  }
417
453
  return out
418
454
  }
455
+ // ===== Verifier 自测指南(verifyUserGuide,task-muxyyvg0)=====
456
+ // userTest 四字段归一(工具通道 board_verdict 的参数 + 文本通道 parseUserTest 的出口共用):
457
+ // gist ≤300 / steps ≤12 条每条 ≤300 / expect ≤600;tier 只认 ui/metric/internal 三档,
458
+ // 非法/缺省一律归 internal(最保守档——宁可标「无用户可感知面」也不伪造可操作指引)。
459
+ // 四字段全空(gist/steps/expect 皆空)→ 返回 null(视为没交指南,不挂字段)。
460
+ export function normalizeUserTest(u) {
461
+ if (!u || typeof u !== 'object') return null
462
+ var tier = String(u.tier == null ? '' : u.tier).trim().toLowerCase().split(/[\s((,,。|。.]/)[0]
463
+ if (['ui', 'metric', 'internal'].indexOf(tier) < 0) tier = 'internal'
464
+ var steps = []
465
+ if (Array.isArray(u.steps)) { for (var i = 0; i < u.steps.length && steps.length < 12; i++) { var s = String(u.steps[i] == null ? '' : u.steps[i]).trim().slice(0, 300); if (s) steps.push(s) } }
466
+ var out = { gist: String(u.gist == null ? '' : u.gist).trim().slice(0, 300), steps: steps, expect: String(u.expect == null ? '' : u.expect).trim().slice(0, 600), tier: tier }
467
+ if (!out.gist && !out.steps.length && !out.expect) return null
468
+ return out
469
+ }
470
+ // 「## 自测指南」段体 → userTest 四字段。字段锚:gist:/tier:/steps:/expect:
471
+ //(兼容中文别名 概要/分级/步骤/预期 与全角冒号;锚词允许前导列表符 `- gist:`)。
472
+ // steps: 行之后的列表行(-/*/+/1./1、/1))逐条收集;其余续行并入当前字段文本。
473
+ export function parseUserTest(body) {
474
+ if (!body) return null
475
+ var raw = { gist: '', steps: [], expect: '', tier: '' }
476
+ var field = null
477
+ var lines = String(body).split('\n')
478
+ for (var i = 0; i < lines.length; i++) {
479
+ var ln = lines[i]
480
+ var m = ln.match(/^\s*(?:[-*+]\s*)?(gist|概要|tier|分级|steps?|步骤|expect|预期)\s*[::]\s*(.*)$/i)
481
+ if (m) {
482
+ var key = m[1].toLowerCase()
483
+ var val = (m[2] || '').trim()
484
+ if (key === 'gist' || key === '概要') { field = 'gist'; raw.gist = val }
485
+ else if (key === 'tier' || key === '分级') { field = 'tier'; raw.tier = val }
486
+ else if (key === 'step' || key === 'steps' || key === '步骤') { field = 'steps'; if (val) raw.steps.push(val) }
487
+ else { field = 'expect'; raw.expect = val }
488
+ continue
489
+ }
490
+ var sm = ln.match(/^\s*(?:[-*+]|\d+[.、))])\s+(.+)$/)
491
+ if (sm && field === 'steps') { raw.steps.push(sm[1].trim()); continue }
492
+ var txt = ln.trim()
493
+ if (!txt) continue
494
+ if (field === 'gist') raw.gist += (raw.gist ? ' ' : '') + txt
495
+ else if (field === 'expect') raw.expect += (raw.expect ? '\n' : '') + txt
496
+ else if (field === 'steps' && raw.steps.length) raw.steps[raw.steps.length - 1] += ' ' + txt
497
+ }
498
+ return normalizeUserTest(raw)
499
+ }
419
500
  // verifier 结论:行首锚定 APPROVED/REJECTED(输出中提到历史驳回字眼不应误判);null = 无法判定
420
501
  export function parseVerdict(output) { var m = (output || '').trim().match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im); return m ? m[1].toUpperCase() : null }
421
502
  // worker 歧义标记
@@ -634,10 +715,42 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
634
715
  // 学习飞轮 v1 软召回(feedbackEnabled 关闭时不出现):只提示"先查历史教训",看板绝不代查——
635
716
  // 环境里有没有笔记/记忆类工具、教训库长什么样,都是 Worker 自己判断的事(零耦合)。
636
717
  if (feedbackEnabled !== false) p += '\n\n' + LESSON_RECALL_HINT
718
+ // git 纪律红线(n-muyaaoo7cqnd 实证事故:并行 Worker 跑 git checkout HEAD -- package.json
719
+ // 把另一 Worker 的在途编辑冲掉):禁止还原命令,只改自己写的文件,要干净基线走 escalate 上报。
720
+ p += '\n\ngit 纪律红线:禁止 git checkout / git restore / git reset --hard / git clean 等还原命令(会冲掉并行 Worker 与你自己的未提交在途编辑);你只需管自己写的文件,别人改了别的文件与你无关;确实需要干净基线时用 board_report(kind=escalate)上报,由主窗口裁决。'
637
721
  p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。\n3. 进展汇报(较大任务):按里程碑推进,每完成一个可验证的里程碑调用一次 board_report(kind="progress", taskId=' + t.id + ', question=一行进展摘要,≤200 字符)。只在有实际产物/结论时报;禁止定时汇报或表演式汇报。'
638
722
  return p
639
723
  }
640
- export function buildVerifierPrompt(t, pack) {
724
+ // ===== Verifier 自测指南契约段(verifyUserGuide 板级开关,默认开,task-muxyyvg0)=====
725
+ // 背景:Verifier 产出只对主窗口说话,用户无法逐张审产出——每张验收卡附一份「用户自测指南」,
726
+ // 用户照着步骤自己验证。四字段:gist(一句人话说改了什么)/ steps[](用户操作步骤,每条一步)/
727
+ // expect(预期看到什么)/ tier(ui=界面可操作 | metric=看指标变化 | internal=纯内部无用户可感知面,
728
+ // internal 时 steps 可空、expect 写「验证靠测试套件」)。
729
+ // 诚实护栏写死在 prompt:只给亲自验过/从 diff 可推导的步骤,不许编没验过的操作;
730
+ // UI 特性给具体路径(哪个区哪个按钮);host-only 改动如实标 internal。
731
+ // 双模:工具通道走 board_verdict 的 userTest 参数;文本通道追加「## 自测指南」段
732
+ //(段体格式 parseUserTest 解析,落账 t.verification.userTest)。
733
+ export var USER_GUIDE_CONTRACT = '\n\n自测指南(给用户看的验收指引,随结论一并提交):\n在结论分段之后追加一段「## 自测指南」,严格按四字段格式:\ngist: <一句人话:这次改了什么,用户能感知到什么>\ntier: ui | metric | internal(三选一,诚实分级:ui=界面可操作验证;metric=看指标/数据变化验证;internal=纯内部改动,无用户可感知面)\nsteps:\n1. <用户操作第一步>\n2. <用户操作第二步>\nexpect: <预期看到什么>\n诚实护栏:steps 只写你亲自验过、或能从 diff 直接推导的步骤,不许编造没验过的操作;UI 特性给具体路径(哪个区哪个按钮);tier=internal 时 steps 可留空,expect 写「验证靠测试套件」。\n工具通道:调用 board_verdict 时把四字段放进 userTest 参数({gist: 一句人话, steps: [步骤,...], expect: 预期, tier: ui|metric|internal})。'
734
+
735
+ // ===== Verifier 验收员加餐(task-muy3gm03):固定 persona + 工具收窄 =====
736
+ // 背景:Verifier 此前只有任务层 prompt 契约,没有「人设」——遇到 Worker 汇报与实证冲突时
737
+ // 容易顺着汇报走。宿主 spawn 面实证(dsh-subagent types.d.ts:136-191 + spawn-in-process
738
+ // lib/index.js:23-29 capabilities 全 true):一次性 start() 支持 persona(scoped
739
+ // deployment:persona-prefix 影子段,只覆盖该子代理的部署人设)与 toolFilter(scoped
740
+ // tools.restrict(),工具从 prompt 消失且拒绝执行)。continuable 面(ContinuableCreateRequest
741
+ // 只有 sessionId/parent/signal)不支持这两个参数——但 verifier 恒走一次性路径
742
+ //(useContinuable 只对 worker 开),挂一次性面即覆盖全部 verifier spawn。
743
+ // ⚠️ persona 走宿主严格 {{…}} 插值(同 deployment persona 模板语义)——本文不得出现连写 {。
744
+ // ⚠️ toolFilter 名字走「响亮未知名校验」:deny 了不存在的工具会直接抛错,故只砍
745
+ // 宿主标配的 web_fetch/web_search,且 dispatch 侧有「剥外挂参数重试」兜底。
746
+ export var VERIFIER_PERSONA = '你是任务看板的独立验收员(Verifier),不是 Worker 的队友。\n1. 独立判断:不轻信 Worker 的汇报与自测描述——「Worker 说已验证」不构成证据,一切以你亲自复跑/核对到的为准。\n2. 实证为准:有硬性验收脚本必须独立复跑并核对真实输出;脚本失败或无法复现成功一律 REJECTED。\n3. 诚实分级:自测指南按真实可验证面分级(ui/metric/internal),不为好看拔高档次,没验过的步骤不写。\n4. 双读者:结论同时写给主窗口(逐条核对证据,含行号)和用户(自测指南人话)——两者都要诚实、可复核。'
747
+ // toolFilter 收窄评估结论(保守口径):只砍明确无关的联网检索(web_fetch/web_search——
748
+ // 验收以本地实证为准,不需联网);其余一律保留:read/grep/glob/pwsh 是核对主力,
749
+ // write/edit 供文件纪律允许的临时/测试文件,board_verdict 是落账通道,note_* 供查历史教训,
750
+ // browser_* 保留(tier=ui 验收可能要真实开页面核对),task_*/看板管理工具子代理本无权限
751
+ //(rpc 层 root 闸门)无需再砍。拿不准的宁可不收窄。
752
+ export var VERIFIER_TOOL_FILTER = { deny: ['web_fetch', 'web_search'] }
753
+ export function buildVerifierPrompt(t, pack, userGuide) {
641
754
  var notes = histNotes(t)
642
755
  var msgs = buildMessages(t)
643
756
  var p = '你是一个一次性任务审核 Verifier。审查下面这个任务的完成质量,给出结论后本会话即销毁。\n\ntaskId: ' + t.id + '\n任务: ' + t.title + '\n描述: ' + (t.description || '').slice(0, 500)
@@ -651,7 +764,14 @@ export function buildVerifierPrompt(t, pack) {
651
764
  // 跑偏归因条款(调研遵循·host 三件套 ②):驳回理由注明「立单缺调研」——归因计入驳回热点统计,
652
765
  // 供主窗口分诊「立单缺料 vs Worker 执行问题」,缺料占高了就该把建卡调研门禁拧紧。
653
766
  p += '\n驳回归因:若 Worker 的产出明显因缺少调研上下文而跑偏/绕路,驳回时请在驳回理由里注明「立单缺调研」(归因会计入驳回热点统计)。'
767
+ // 文件纪律(task-muy3gm03,用户口径「验收时可以写必要的测试文件,不要动工程代码」):
768
+ // 写死进 prompt 的硬纪律 + 事后兜底威慑(touches 审计:Worker 交付的 diff 概要与任务声明的
769
+ // touches 即改动范围基线,越界即驳回——这层审计对 verifier 自己同样生效)。
770
+ p += '\n文件纪律:验收时可以写必要的临时/测试文件(放 _scratch 或测试目录,验收结束后自行清理干净);禁止改动工程代码与文档(README 等)。事后有 touches 审计兜底:Worker 交付的 diff 概要与任务声明的 touches 是改动范围基线,发现越界改动(含你自己留下的改动痕迹)一律驳回。'
654
771
  p += '\n\n结论契约(双模,工具优先):\n1. 优先调用 board_verdict 工具(taskId=' + t.id + ', verdict=approved/rejected, summary=测试概要, checks=逐条核对证据含行号)。\n2. 工具不可用则首行 APPROVED: <结论> 或 REJECTED: <结论>,然后 ## 测试概要 / ## 核对项 分段。'
772
+ // 自测指南段(verifyUserGuide 开关门禁,缺省开):整段追加在 prompt 末尾;关掉 = 逐字无此段(省 token),
773
+ // 落账侧(settleVerifier/board_verdict)同开关门禁——关时即使输出带了段也不挂 userTest 字段。
774
+ if (userGuide !== false) p += USER_GUIDE_CONTRACT
655
775
  return p
656
776
  }
657
777
 
@@ -803,6 +923,80 @@ export function createTaskWarnings(t) {
803
923
  return out
804
924
  }
805
925
 
926
+ // ===== 任务起草 lint(proposal n-muwlpbspl3w2):四规则软警告,存 task.lintWarnings[],不阻塞 =====
927
+ // 与上方「调研门禁」(createTaskWarnings,瞬态 warning 字段)正交:那是「缺调研材料」的即时软提示;
928
+ // 这是「起草质量」的持久诊断(粒度过粗/双仓库歧义/缺验收/标题缺动词/描述空),随卡落盘、
929
+ // get-tasks 透出、详情页 ⚠️ 行展示。纯函数:双仓库歧义读盘(io 注入),本体零 IO 可单测。
930
+ // 四规则(各一触发 + 各一不触发,单测锁定):
931
+ // ① touches 含整树 glob(src/** 级)→ 粒度过粗,几乎锁整仓
932
+ // ② touches 相对路径在 ownerCwd 多个直接子目录下同名命中 → 路径双仓库歧义
933
+ // ③ pipeline=full 但无 acceptance 验收脚本 → 建议带验收命令
934
+ // ④ 标题缺动作动词 / 描述为空 → 建议补指令式标题 / 补目标约束
935
+ export var TREE_GLOB_LINT = 'touches 粒度过粗,几乎锁整仓'
936
+ export var AMBIGUOUS_PATH_LINT = '路径双仓库歧义,建议加仓库前缀'
937
+ export var FULL_NO_ACCEPTANCE_LINT = 'full 管线建议带验收命令'
938
+ export var TITLE_NO_VERB_LINT = '标题缺动作动词,建议改为「动词+宾语」指令式'
939
+
940
+ // 标题动作动词白名单(中文指令式任务标题高频动词,命中任一即算「有动词」)。
941
+ // 只做软提示:宁可漏检不可误报(漏了最多不亮这条),因此不做「非动词开头即判缺动词」的严格反例。
942
+ var TITLE_VERBS = /添加|新增|修复|实现|重构|优化|支持|调整|更新|删除|迁移|拆分|整理|调研|设计|接入|改造|移除|升级|编写|撰写|起草|补充|完善|收敛|扩展|统一|替换|清理|测试|验证|审查|复核|审计|沉淀|回写|生成|构建|发布|上线|评审|分析|汇总|统计|梳理|排查|定位|解决|处理|切换|对齐|收口|接线|透出|承接|重建|适配|放宽|收紧|拦截|放行|归一|聚合|装配|注入|投递|挂载|卸载|裁剪|截断|折叠|去重|合并|回滚|撤销|归档|恢复|继承|出清|释放|覆盖|包裹|转发|代理|缓存|落盘|读盘|心跳|轮询|结算|推进|回填|重命名|改名|重写|重排|重试|重投|重派|复查|复跑|补全|补齐|补写|补录|追加|跟进|追踪|溯源|归因|归类|导出|导入|展示|渲染|刷新|跳转|筛选|排序/
943
+
944
+ // touches 相对路径的「仓库内目录前缀」:剥掉 glob 尾段,得到跨仓库判重锚点。
945
+ // 'packages/dsh-agent-board/**' → 'packages/dsh-agent-board';'src/x.mjs' → 'src';
946
+ // 'a/b/c.mjs' → 'a/b';裸文件名(无 '/')→ ''(无目录前缀,不参与双仓库判重)。
947
+ export function touchDirPrefix(p) {
948
+ var v = normTouch(p)
949
+ if (!v || v === '**') return ''
950
+ var i = v.lastIndexOf('/')
951
+ return i < 0 ? '' : v.slice(0, i)
952
+ }
953
+
954
+ // 双仓库歧义:某条 touches 相对路径的目录前缀,在 root 的 ≥2 个直接子目录下都存在 → 命中同名仓库副本。
955
+ // 返回命中的相对路径原文(首个)或 null;io = { root, subdirs[], dirExists(path) } 由调用方注入(读盘)。
956
+ // 绝对路径/盘符(C:)/裸文件名不参与判重(裸文件名没有目录锚点,判不了)。
957
+ export function ambiguousTouch(touches, io) {
958
+ if (!io || typeof io.dirExists !== 'function') return null
959
+ var list = Array.isArray(touches) ? touches : []
960
+ var subs = Array.isArray(io.subdirs) ? io.subdirs : []
961
+ if (!subs.length) return null
962
+ var root = String(io.root || '')
963
+ for (var i = 0; i < list.length; i++) {
964
+ var p = list[i]
965
+ if (typeof p !== 'string' || !p.trim()) continue
966
+ var v = normTouch(p)
967
+ if (!v || path.isAbsolute(v) || /^[a-zA-Z]:/.test(v)) continue
968
+ var dir = touchDirPrefix(v)
969
+ if (!dir) continue
970
+ var hits = 0
971
+ for (var j = 0; j < subs.length && hits < 2; j++) {
972
+ var cand = path.join(root, subs[j], dir)
973
+ var ok = false
974
+ try { ok = !!io.dirExists(cand) } catch (_) { ok = false }
975
+ if (ok) hits++
976
+ }
977
+ if (hits >= 2) return p
978
+ }
979
+ return null
980
+ }
981
+
982
+ // 起草质量 lint 主入口:返回 string[](task.lintWarnings 字段内容)。io 缺省时规则②退化为不触发(零 IO 安全)。
983
+ export function draftLint(t, io) {
984
+ var out = []
985
+ if (!t || typeof t !== 'object') return out
986
+ var touches = Array.isArray(t.touches) ? t.touches : []
987
+ // ① 整树 glob(多条只报一次)
988
+ for (var i = 0; i < touches.length; i++) { if (isTreeGlob(touches[i])) { out.push(TREE_GLOB_LINT); break } }
989
+ // ② 双仓库歧义(附命中路径,便于定位该加哪个仓库前缀)
990
+ var amb = ambiguousTouch(touches, io)
991
+ if (amb) out.push(AMBIGUOUS_PATH_LINT + '(' + amb + ')')
992
+ // ③ full 无 acceptance(pipeline 空串按默认 full 口径)
993
+ if ((t.pipeline || 'full') === 'full' && !String(t.acceptance || '').trim()) out.push(FULL_NO_ACCEPTANCE_LINT)
994
+ // ④ 标题缺动词 / 描述为空(描述为空复用调研门禁 EMPTY_DESC_WARNING 文案,单一事实源)
995
+ if (!TITLE_VERBS.test(String(t.title || ''))) out.push(TITLE_NO_VERB_LINT)
996
+ if (!String(t.description || '').trim()) out.push(EMPTY_DESC_WARNING)
997
+ return out
998
+ }
999
+
806
1000
  // ===== touches → suggestedContextFiles 自动桥接(调研遵循·host 三件套 ①)=====
807
1001
  // 让遵守成为最省力路径:建卡触发「无调研上下文」warning 时,直接把 touches 里的具体文件
808
1002
  // 提炼成可一键采纳的 contextFiles 建议(响应挂 suggestedContextFiles 字段)。
package/lib/dispatch.mjs CHANGED
@@ -7,7 +7,7 @@ import { statSync } from 'node:fs'
7
7
  import * as core from './core.mjs'
8
8
  import { readRunUsage, findRunLog, readLogBytes, readLogFrames } from './usage.mjs'
9
9
  import { splitRuleOf, pushRejectLesson } from './policy.mjs'
10
- const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection } = core
10
+ const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
11
11
 
12
12
  export function createDispatch(ctx, state, deps) {
13
13
  // 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
@@ -32,6 +32,17 @@ export function createDispatch(ctx, state, deps) {
32
32
  var knownSessions = state.knownSessions
33
33
  var teamModeCache = state.teamModeCache
34
34
 
35
+ // ===== 运行时健康自检(task-muxhrkbg)心跳打点 =====
36
+ // 本模块是三处心跳的唯一写入方:poolCycle 成功轮 / settleRunRecord 成功结算 / 幽灵回收 >0 / spawn 成功。
37
+ // 读侧在 rpc get-tasks(health.computeRuntimeHealthHints 现算 hint);纯内存不落盘。
38
+ // state 缺字段时就地补(测试桩/老宿主兼容,与 rpc.mjs mainWindowUsageCache 同例)。
39
+ function poolHealthFor(sid) {
40
+ if (!state.poolHealth) state.poolHealth = {}
41
+ var ph = state.poolHealth[sid]
42
+ if (!ph) { ph = state.poolHealth[sid] = { bornAt: Date.now() } }
43
+ return ph
44
+ }
45
+
35
46
  function makeSignal() { try { return new AbortController().signal } catch (_) { return { aborted: false, addEventListener: function () {}, removeEventListener: function () {} } } }
36
47
 
37
48
  // ===== 状态流转(已抽取到 lib/core.mjs)=====
@@ -190,8 +201,17 @@ export function createDispatch(ctx, state, deps) {
190
201
  var kids = []
191
202
  try { var hsnap = await rt(sid); kids = gsb(t.id, (hsnap && hsnap.tasks) || []) } catch (_) {}
192
203
  promptText = buildHookPrompt(t, role === 'hook-pre' ? 'pre' : 'post', kids)
193
- } else promptText = role === 'worker' ? buildWorkerPrompt(t, pack, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, pack)
204
+ } else promptText = role === 'worker' ? buildWorkerPrompt(t, pack, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, pack, cfg(dsnap).verifyUserGuide)
194
205
  var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: promptText }], parent: parent, signal: makeSignal() }
206
+ // ===== Verifier 验收员加餐(task-muy3gm03):persona + toolFilter 只挂 verifier =====
207
+ // ① persona = 固定验收员人设(scoped deployment:persona-prefix 影子段,只覆盖该子代理);
208
+ // ② toolFilter = 保守收窄(deny web_fetch/web_search:验收以本地实证为准不需联网,其余工具
209
+ // 一律保留——评估口径见 core.mjs VERIFIER_TOOL_FILTER 头注释)。
210
+ // 两条 spawn 路径覆盖口径:continuable 面(ContinuableCreateRequest 仅 sessionId/parent/signal)
211
+ // 不支持 persona/toolFilter——但 verifier 恒走一次性 start()(useContinuable 只对 worker 开),
212
+ // 故挂在这里即覆盖全部 verifier spawn(含手动「派发验收」与自动派发两条入口,共用 spawnOneShot)。
213
+ // worker 请求不挂任何加餐(spawn 参数逐字 parity,单测锁定)。
214
+ if (role === 'verifier') { req.persona = VERIFIER_PERSONA; req.toolFilter = VERIFIER_TOOL_FILTER }
195
215
  if (modelOverride) {
196
216
  // list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
197
217
  // 但 AgentOptions 的 provider 和 model 是分开的——整串塞进 model 会报 UNKNOWN_MODEL
@@ -219,9 +239,12 @@ export function createDispatch(ctx, state, deps) {
219
239
  run = await subagents.start(providerName, req)
220
240
  }
221
241
  } catch (e) {
222
- if (modelOverride) {
223
- console.error('[task-board] model override failed, fallback to parent model:', String(e))
224
- delete req.agentOptions
242
+ // 外挂参数回退(既有模型覆盖通道,task-muy3gm03 起并入 verifier 加餐 persona/toolFilter):
243
+ // provider 不支持某能力 / toolFilter 命中未知名时服务面 fail-loud 拒收(绝不静默忽略)——
244
+ // 剥光外挂参数用裸请求再试一次(文件纪律条款在 prompt 里,加餐剥掉纪律不丢),仍失败才放弃本轮。
245
+ if (modelOverride || req.persona !== undefined || req.toolFilter !== undefined) {
246
+ console.error('[task-board] spawn extras failed (model/persona/toolFilter), fallback to bare request:', String(e))
247
+ delete req.agentOptions; delete req.persona; delete req.toolFilter
225
248
  try {
226
249
  if (useContinuable) {
227
250
  var cs2 = await subagents.startContinuable({ provider: providerName, label: req.label, request: req, signal: req.signal })
@@ -402,6 +425,9 @@ export function createDispatch(ctx, state, deps) {
402
425
  try { await accumulateRunUsage(sid, rec) } catch (e) { console.error('[task-board] usage 收尾失败 (task ' + rec.taskId + '):', String(e)) }
403
426
  // ③ 摘除两级超时臂:软臂不再刷假告警,硬臂不再对已结算的闲置会话补一枪 interrupt。
404
427
  try { if (typeof rec.disarm === 'function') rec.disarm() } catch (_) {}
428
+ // ② settle 通道心跳(运行时健康自检):三件套各自幂等且各自 catch 永不抛,
429
+ // 走到这里即「一次成功结算活动」——rpc 侧据此判定「结算通道 >30min 无活动」黄条。
430
+ poolHealthFor(sid).settleLastOkAt = Date.now()
405
431
  }
406
432
 
407
433
  async function settleRun(sid, rec, res, err) {
@@ -639,6 +665,11 @@ export function createDispatch(ctx, state, deps) {
639
665
  // 只增不减(max 收敛):异常情况下读到较小值也不让水位倒退(倒退=把已结算帧再记一遍)。
640
666
  t.runs[ri].usageSeq = Math.max(num0(t.runs[ri].usageSeq), num0(u.maxSeq))
641
667
  t.runs[ri].usageRecorded = true
668
+ // 模型补落账(task-muxhshfu 记分卡卡1①):派发未显式覆盖模型时条目 model='',
669
+ // 而日志 request/context 里记着真实模型(u.model,与任务级 u.models 小计同源)——
670
+ // 结算时顺手回填,记分卡卡2 的「模型×场景」聚合就不必再走按任务级占比摊派的近似路径。
671
+ // 只填空不覆盖:派发显式覆盖的模型名优先(aggregateUsageSummary 模型 key 同一优先序)。
672
+ if (!t.runs[ri].model && u.model) t.runs[ri].model = String(u.model)
642
673
  break
643
674
  }
644
675
  }
@@ -791,6 +822,9 @@ export function createDispatch(ctx, state, deps) {
791
822
  var vsecs = parseSections(trimmed)
792
823
  delete t.stuckSince; delete t.verifyRetries; delete t.lastError // 成功给出结论:卡死标记/重试计数/最近失败原因一并清除
793
824
  t.verification = { verdict: approved ? 'approved' : 'rejected', summary: vsecs.verifySummary || trimmed.slice(0, 600), checks: vsecs.checks || '', at: new Date().toISOString(), by: String(rec.id) }
825
+ // 自测指南落账(verifyUserGuide 开关门禁,task-muxyyvg0):开关开且解析出「## 自测指南」段才挂
826
+ // userTest;缺段/开关关 → 字段不挂(client 详情块与报告「本版自测清单」段整块不渲染)
827
+ if (cfg(d).verifyUserGuide !== false && vsecs.userTest) t.verification.userTest = vsecs.userTest
794
828
  verifyApply(d, t, String(rec.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
795
829
  if (!approved) {
796
830
  t.rejectCount = (t.rejectCount || 0) + 1
@@ -1039,7 +1073,13 @@ export function createDispatch(ctx, state, deps) {
1039
1073
  var runs = runsFor(sid)
1040
1074
  var snap = await rt(sid)
1041
1075
  // 本轮主体整段进 try:任一环节抛异常都只作废本轮(console 留痕),下一轮心跳 / kickCycle 照常进来。
1042
- try { return await poolCycleBody(sid, info, runs, snap) }
1076
+ // ① 派发循环心跳(运行时健康自检):主体正常返回(含空闲快进早退)才算「成功跑完一轮」刷新时间戳;
1077
+ // 异常被隔离时不刷新——连续 >5min 不刷新 + 有可派卡 + 有空位 = rpc 侧亮「派发循环疑似冻结」红条。
1078
+ try {
1079
+ var cycleRet = await poolCycleBody(sid, info, runs, snap)
1080
+ poolHealthFor(sid).poolLastOkAt = Date.now()
1081
+ return cycleRet
1082
+ }
1043
1083
  catch (e) { console.error('[task-board] poolCycle 本轮异常(已隔离,下一轮照常)([' + sid + ']):', String(e)); return undefined }
1044
1084
  }
1045
1085
  // 本轮主体:reconcile → 幽灵占位回收 → 活跃度/上限闸门 → 持锁(回收+claim+池快照)→ 锁外 spawn。
@@ -1053,7 +1093,12 @@ export function createDispatch(ctx, state, deps) {
1053
1093
  // 故有回收时重读一次快照——否则下面空闲快进的 wt(snap) 会拿旧快照把收尾结果覆盖回去。
1054
1094
  var reapedN = 0
1055
1095
  try { reapedN = await reapGhostRecs(sid, snap) } catch (e) { console.error('[task-board] 幽灵占位回收失败(本轮跳过,派发照常):', String(e)) }
1056
- if (reapedN > 0) { try { snap = await rt(sid) } catch (_) {} }
1096
+ if (reapedN > 0) {
1097
+ try { snap = await rt(sid) } catch (_) {}
1098
+ // ③ 幽灵回收可视化(运行时健康自检):本轮回收 >0 留一条一次性记录(90s 保鲜),
1099
+ // rpc get-tasks 读到即在「架构健康」区亮一条 info 并消费——幽灵回收从此在 GUI 可见。
1100
+ poolHealthFor(sid).reapNote = { n: reapedN, at: Date.now() }
1101
+ }
1057
1102
  var activeW = 0, activeV = 0
1058
1103
  // 角色口径:worker 计入 activeW(占 Worker 并发位);verifier 与 hook run(hook-pre/hook-post)
1059
1104
  // 统一计入 activeV——hook run 不是 Worker,不该挤占 maxWorkers 并发位,但它确实是一条在跑的 run,
@@ -1232,6 +1277,8 @@ export function createDispatch(ctx, state, deps) {
1232
1277
  }
1233
1278
  if (!rec) rec = await spawnOneShot(sid, sp.t, sp.role)
1234
1279
  if (rec) {
1280
+ // 成功派发计数(运行时健康自检):续跑命中与 fresh spawn 都算一次成功派发
1281
+ var __ph = poolHealthFor(sid); __ph.dispatchOk = (__ph.dispatchOk || 0) + 1; __ph.lastDispatchAt = Date.now()
1235
1282
  // claim 占位换成真实 run id;verifier/hook-post run 单独记(claimedBy 保留 worker 的,供详情页跳转会话)
1236
1283
  await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t) { if (sp.role === 'worker' && t.claimedBy === 'spawn-pending') t.claimedBy = String(rec.id); if ((sp.role === 'verifier' || sp.role === 'hook-post') && t.verifierRun === 'spawn-pending') { t.verifierRun = String(rec.id); t.verifierRunAt = new Date().toISOString() }; if (sp.role === 'hook-pre' && t.hooks && t.hooks.pre) t.hooks.pre.runId = String(rec.id) }; return t }, true)
1237
1284
  // 派发即回执:spawn 真成功后才入队(占位阶段失败不通知);经 deps 注入,未注入静默跳过(老 host 兼容)
package/lib/health.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // 窗口化(近 50 张卡)而非累计:架构优化落地后老数据自动滑出窗口不再报警——
5
5
  // 健康区同时是「拆分是否有效」的客观验收证据。
6
6
  // 信号源不做裁判:只产提示文案,决策永远是人。
7
- import { normTouch } from './core.mjs'
7
+ import { normTouch, pickDispatch } from './core.mjs'
8
8
 
9
9
  // ===== 阈值常量(全部置顶,可按实际使用手感调整;只影响之后的计算,不追溯老数据)=====
10
10
  var WINDOW_SIZE = 50 // 统计窗口:按 createdAt 取最近 N 张卡(含归档)
@@ -161,3 +161,70 @@ export function computeHealthHints(tasks) {
161
161
  }
162
162
  return hints.slice(0, MAX_HINTS)
163
163
  }
164
+
165
+ // ===== 运行时健康自检(task-muxhrkbg,proposal n-muwlpbr3wkhc)=====
166
+ // 与上面的静态架构信号(L1)正交:这里看「运行时心跳」——派发循环是否在转、结算通道是否活着、
167
+ // 幽灵回收是否发生过。事故背景:2026-10-06 continuable 上线事故——settle 链异常导致 poolCycle
168
+ // 冻结 ~45 分钟无人知(发现靠 e2e 翻车 + 用户肉眼);L1 只有静态信号(孤儿/僵尸卡),没有运行时监护。
169
+ // 数据源 = dispatch.mjs 写入的内存心跳 state.poolHealth[sid]({ bornAt, poolLastOkAt, settleLastOkAt,
170
+ // reapNote }),本函数仍是纯函数:不读盘、不删数据——reapNote 的一次性消费由调用方按返回的
171
+ // consumeReapNote 旗执行(rpc get-tasks 摘除),测试因此可以纯断言返回值。
172
+ // 客户端零改动:hint 通道是纯文本数组直渲(level 仅 warn/info 两档配色),①的「红条」用 warn 级 +
173
+ // 文案 🔴 前缀表达(真要独立红色档需 client 加 level——本卡范围外,留给主窗口裁决)。
174
+ var POOL_STALE_MS = 5 * 60000 // ① 派发循环心跳:上次成功轮距今超 5min(且有活可派、有空位)→ 冻结疑似
175
+ var SETTLE_STALE_MS = 30 * 60000 // ② 结算通道静默:有在跑卡但超 30min 无任何成功结算 → 黄级提示
176
+ var REAP_NOTE_TTL_MS = 90000 // ③ 幽灵回收一次性记录的保鲜期(与 dispatchInfo 90s TTL 同口径)
177
+
178
+ // HH:mm 本地时分(hint 文案里的「上次成功 HH:mm」)
179
+ function hhmm(ms) { var d = new Date(ms); var h = d.getHours(), m = d.getMinutes(); return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m }
180
+
181
+ // board = get-tasks 的看板对象(只用 .tasks);rt = state.poolHealth[sid] 心跳记录(undefined → 无法判定,全不亮);
182
+ // opts = { capW: 当前 Worker 空位数(manual 模式 / 无活 root 时调用方传 0), now: 毫秒(测试可 mock),
183
+ // alive: root 是否存活(false 时 ①② 都不判——死会话本就不该派发/结算,不是冻结) }。
184
+ // 「可派发」判定复用 core.pickDispatch 全闸门口径(依赖/frozen/歧义/manual/direct/pre-hook/touches 锁):
185
+ // capW=1 探针有 pendings 产出 = 存在「无任何阻塞原因可派发」的卡;全被锁/被依赖挡 → 不误报。
186
+ // 返回 { hints: [{level, text}], consumeReapNote: 读到 reapNote(无论是否过期)即为 true }。
187
+ export function computeRuntimeHealthHints(board, rt, opts) {
188
+ var out = { hints: [], consumeReapNote: false }
189
+ if (!board || !Array.isArray(board.tasks)) return out
190
+ if (!rt || typeof rt !== 'object') return out
191
+ var now = (opts && typeof opts.now === 'number') ? opts.now : Date.now()
192
+ var capW = (opts && typeof opts.capW === 'number') ? opts.capW : 0
193
+ var alive = !opts || opts.alive !== false
194
+ var bornAt = typeof rt.bornAt === 'number' ? rt.bornAt : now
195
+ var tasks = board.tasks
196
+
197
+ if (alive) {
198
+ // --- ① 派发循环心跳:有可派卡 + 池有空位 + 上次成功轮 >5min ---
199
+ // poolLastOkAt 缺失时退到 bornAt(首次见到本板的时刻):host 重启后心跳全断也能在 5min 宽限后亮条,
200
+ // 而正常路径首轮 cycle(≤15s)会立刻刷新它,不会误报。
201
+ var poolRef = typeof rt.poolLastOkAt === 'number' ? rt.poolLastOkAt : bornAt
202
+ if (capW > 0 && now - poolRef > POOL_STALE_MS) {
203
+ var probe = pickDispatch({ tasks: tasks }, 1, 0, null)
204
+ if (probe.pendings.length > 0) {
205
+ var staleMin = Math.floor((now - poolRef) / 60000)
206
+ out.hints.push({ level: 'warn', text: '🔴 派发循环疑似冻结(上次成功 ' + hhmm(poolRef) + ',已 ' + staleMin + ' 分钟无派发)——有可派发任务且池有空位,请查看 host 控制台 [task-board] 日志或重载看板插件' })
207
+ }
208
+ }
209
+ // --- ② settle 通道存活:有 in-progress 卡 + >30min 无任何成功结算(黄级)---
210
+ // settleLastOkAt 同样缺省退 bornAt(重启后 30min 宽限,不拿「无记录」当「已静默」)。
211
+ var hasInProgress = tasks.some(function (t) { return t && t.status === 'in-progress' })
212
+ if (hasInProgress) {
213
+ var settleRef = typeof rt.settleLastOkAt === 'number' ? rt.settleLastOkAt : bornAt
214
+ if (now - settleRef > SETTLE_STALE_MS) {
215
+ out.hints.push({ level: 'warn', text: '结算通道长时间无活动(上次成功结算 ' + hhmm(settleRef) + ',已 ' + Math.floor((now - settleRef) / 60000) + ' 分钟)——有任务在执行中但超过 30 分钟无任何 run 结算,settle 链路可能异常' })
216
+ }
217
+ }
218
+ }
219
+
220
+ // --- ③ 幽灵回收可视化:本轮回收 >0 留一条一次性 info(读到即消费;过期未读静默丢弃不亮条)---
221
+ // 展示序放在最后:它只是历史事件留痕,①② 是进行中的事故。
222
+ var rn = rt.reapNote
223
+ if (rn && typeof rn.n === 'number' && rn.n > 0 && typeof rn.at === 'number') {
224
+ out.consumeReapNote = true
225
+ if (now - rn.at <= REAP_NOTE_TTL_MS) {
226
+ out.hints.push({ level: 'info', text: '本轮回收 ' + rn.n + ' 个幽灵活跃表项(' + hhmm(rn.at) + ')——失效表项已自动摘除,若反复出现请排查 settle 链路(host 日志 [task-board])' })
227
+ }
228
+ }
229
+ return out
230
+ }