dsh-agent-board 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.mjs CHANGED
@@ -260,6 +260,27 @@ export function archiveApply(d, t, actor, note) {
260
260
  if (p) r.parentUpdated = true
261
261
  return r
262
262
  }
263
+ // 取消落定(task_cancel 工具与 cancel-task RPC 共享,单一行为口径;门禁 cancelGate 留在
264
+ // rpc.mjs——它只需判 status + claimedBy,无需 IO,与 archiveErr/deleteGate 同居「门禁在接线层、
265
+ // 状态流转在 core」的分工)。置 cancelled + 记历史(actor + 理由 note 可选)。
266
+ // spawn-pending 占位回退:claimedBy='spawn-pending' 是派发瞬时占位(无真实 run,spawn 尚在途),
267
+ // 取消时与 spawn 失败回退同口径清认领位(claimedBy/claimedAt → null),不留死占位。
268
+ // 取消不级联子卡(删除有「未归档子卡」门禁防悬空引用;取消只标记本卡,父卡不删,子卡 parentId
269
+ // 仍指向有效卡);但取消子卡同样触发父卡自动收口(isChildSettled 已计 cancelled)。
270
+ export function cancelApply(d, t, actor, note) {
271
+ var ps = t.status
272
+ if (t.claimedBy) { t.claimedBy = null; t.claimedAt = null } // spawn-pending / 残留认领位回退
273
+ t.status = 'cancelled'
274
+ t.cancelledAt = new Date().toISOString()
275
+ t.resolvedAt = null
276
+ t.resolution = null
277
+ delete t.stuckSince
278
+ ah(t, ps, 'cancelled', actor, note || 'cancelled')
279
+ var r = { ok: true, task: t }
280
+ var p = maybeAutoCloseParent(d, t) // 取消路径同样触发父卡自动收口(isChildSettled 已计 cancelled)
281
+ if (p) r.parentUpdated = true
282
+ return r
283
+ }
263
284
 
264
285
  // ===== 史诗 hooks=agent run(宿主生命周期接线)=====
265
286
  // 定位:hook 点 = 一次**真实 agent 运行**(不是声明式命令、不走 shell),挂在 epic 卡上、
@@ -571,6 +592,28 @@ export function pushRejection(t, summary, checks, at, by) {
571
592
  return true
572
593
  }
573
594
 
595
+ // ===== 迟到 verdict 落账(验收时序三洞 ③,用户 n-mv0ebretgv1o)=====
596
+ // 事故:主窗口在 Verifier 活跃期 task_verify 抢批(早 0.4s)+ 归档,Verifier 的独立结论与自测
597
+ // 指南被静默吞掉、无回执。修法:verifier 结论(工具通道 board_verdict / 文本通道 settleVerifier)
598
+ // 落到已 resolved/archived/cancelled 卡时不再静默丢弃——落 t.lateVerdict 留痕。
599
+ // 纯函数(零 IO):写 t.lateVerdict;userTest 若开关开且解析得出 → 挂 t.lateVerdict.userTest,
600
+ // 并补挂 t.verification.userTest(原 verification 缺 userTest 时;主窗口 task_verify approved 不写
601
+ // t.verification,此时用迟到结论的 verdict/summary/checks 兜底建 verification,避免详情页渲染空验收块)。
602
+ // 返回 { lateRejected, wasApproved }:调用方据此决定要不要发 err 级通知 + healthHint
603
+ // (I/O 留在调用方,core 保持零依赖)。wasApproved 必须先算——下方可能覆写 t.verification。
604
+ export function lateVerdictApply(t, verdict, summary, checks, userTest, at, by, verifyUserGuideOn) {
605
+ var approved = verdict === 'approved'
606
+ var wasApproved = t.status === 'resolved' || (t.status === 'archived' && !!(t.verifiedAt || t.verifiedBy || (t.verification && t.verification.verdict === 'approved')))
607
+ t.lateVerdict = { verdict: verdict, summary: summary || '', checks: checks || '', at: at || new Date().toISOString(), by: by || 'system', note: '迟到结论:卡在落定后收到' }
608
+ var ut = verifyUserGuideOn !== false ? normalizeUserTest(userTest) : null
609
+ if (ut) {
610
+ t.lateVerdict.userTest = ut
611
+ if (!t.verification) t.verification = { verdict: verdict, summary: summary || '', checks: checks || '', at: at || new Date().toISOString(), by: by || 'system' }
612
+ if (!t.verification.userTest) t.verification.userTest = ut
613
+ }
614
+ return { lateRejected: !approved, wasApproved: wasApproved }
615
+ }
616
+
574
617
  // ===== 一次性子代理 prompt 构建(上下文由主窗口 agent 写入 description/instructions,系统只追加生命周期记录)=====
575
618
  // 学习飞轮 v1 软召回引导(feedbackEnabled 开时才拼进 prompt):环境里若有笔记/记忆类工具,先查历史教训再动手。
576
619
  // 只是"提示先搜"——看板不代查、不调用任何记忆工具、也无从知道有没有这类工具(零耦合)。
@@ -1104,7 +1147,10 @@ export function tasksHash(tasks) {
1104
1147
  Array.isArray(t.tags) ? t.tags.join(',') : '',
1105
1148
  t.claimedBy, t.claimedAt, t.resolvedAt, t.verifiedAt, t.archivedAt,
1106
1149
  t.lastError, t.lastProgress && t.lastProgress.text,
1107
- t.frozen ? '1' : '', t.stuckSince, t.escalation && t.escalation.question
1150
+ t.frozen ? '1' : '', t.stuckSince, t.escalation && t.escalation.question,
1151
+ t.reviewedAt,
1152
+ t.reviewHint ? String(t.reviewHint.score) + ':' + (Array.isArray(t.reviewHint.reasons) ? t.reviewHint.reasons.join('|') : '') : '',
1153
+ t.lateVerdict ? (t.lateVerdict.verdict + ':' + t.lateVerdict.at) : ''
1108
1154
  ].join('\u0001'))
1109
1155
  }
1110
1156
  var s = parts.join('\u0002')
@@ -1112,3 +1158,80 @@ export function tasksHash(tasks) {
1112
1158
  for (var j = 0; j < s.length; j++) h = (((h << 5) + h) ^ s.charCodeAt(j)) >>> 0
1113
1159
  return h.toString(36)
1114
1160
  }
1161
+
1162
+ // ===== 风险队列「待你过目」:reviewHint 现算(异常驱动审视①,task-muzikj1y)=====
1163
+ // 纯函数零 IO 零落库:唯一会落库的新字段是 reviewedAt(rpc.mjs mark-reviewed 落),
1164
+ // reviewHint 每轮 get-tasks 现算(四硬信号恒计入 + 两软信号仅质量异动告警激活期间计入)。
1165
+ // 信号口径:
1166
+ // 硬① 被驳回过:rejectCount>0 或 verification.verdict='rejected' 或 messages 有 rejection 包 或 runs 含 rejected 结局
1167
+ // 硬② 上报过歧义:messages 有 arbitration 裁决包
1168
+ // 硬③ touches 碰核心文件:任一条 touches 的 basename ∈ {dispatch.mjs, rpc.mjs, core.mjs}
1169
+ // 硬④ full 无 acceptance:pipeline=full 且无硬性验收脚本
1170
+ // 软⑤ diff>300 行:deliverable.diff(git diff --stat)解析出的总增删行数 >300(仅告警态)
1171
+ // 软⑥ 续跑≥2 次:runs 中 resume:true 条目数 ≥2(仅告警态)
1172
+ export var REVIEW_CORE_FILES = ['dispatch.mjs', 'rpc.mjs', 'core.mjs']
1173
+ export var REVIEW_DIFF_LINES = 300
1174
+ export var REVIEW_RESUME_MIN = 2
1175
+ function reviewBaseName(p) { var s = String(p == null ? '' : p); var i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')); return i >= 0 ? s.slice(i + 1) : s }
1176
+ function reviewTouchesCore(touches) {
1177
+ var list = Array.isArray(touches) ? touches : []
1178
+ for (var i = 0; i < list.length; i++) { if (REVIEW_CORE_FILES.indexOf(reviewBaseName(list[i])) >= 0) return true }
1179
+ return false
1180
+ }
1181
+ function reviewWasRejected(t) {
1182
+ if (!t) return false
1183
+ if ((t.rejectCount || 0) > 0) return true
1184
+ if (t.verification && t.verification.verdict === 'rejected') return true
1185
+ if (Array.isArray(t.messages)) { for (var i = 0; i < t.messages.length; i++) { var m = t.messages[i]; if (m && m.kind === 'rejection') return true } }
1186
+ if (Array.isArray(t.runs)) { for (var j = 0; j < t.runs.length; j++) { var r = t.runs[j]; if (r && r.outcome === 'rejected') return true } }
1187
+ return false
1188
+ }
1189
+ function reviewHasArbitration(t) {
1190
+ if (!t || !Array.isArray(t.messages)) return false
1191
+ for (var i = 0; i < t.messages.length; i++) { var m = t.messages[i]; if (m && m.kind === 'arbitration') return true }
1192
+ return false
1193
+ }
1194
+ // diffStat 解析:优先读汇总行「N files changed, X insertions(+), Y deletions(-)」的增删合计;
1195
+ // 无汇总行回退按每文件「 | N 」改动数累加(git diff --stat 每行路径 + 改动数)。解析不出返回 0(不计软信号)。
1196
+ function reviewDiffLines(t) {
1197
+ var text = t && t.deliverable && t.deliverable.diff ? String(t.deliverable.diff) : ''
1198
+ if (!text) return 0
1199
+ var mIns = text.match(/(\d+)\s+insertions?\(\+\)/)
1200
+ var mDel = text.match(/(\d+)\s+deletions?\(-\)/)
1201
+ var ins = mIns ? (parseInt(mIns[1], 10) || 0) : 0
1202
+ var del = mDel ? (parseInt(mDel[1], 10) || 0) : 0
1203
+ if (ins || del) return ins + del
1204
+ var total = 0
1205
+ var re = /\|\s*(\d+)\s/g
1206
+ var mm
1207
+ while ((mm = re.exec(text)) !== null) total += parseInt(mm[1], 10) || 0
1208
+ return total
1209
+ }
1210
+ function reviewResumeCount(t) {
1211
+ if (!t || !Array.isArray(t.runs)) return 0
1212
+ var n = 0
1213
+ for (var i = 0; i < t.runs.length; i++) { var r = t.runs[i]; if (r && r.resume === true) n++ }
1214
+ return n
1215
+ }
1216
+ // 返回 { [taskId]: { score, reasons[] } }(含 score=0 的空 reasons 条目,调用方按需透出)。
1217
+ // opts.qualityAlertActive = 质量异动告警黄条激活态(读 qualityChangeHints 输出非空),软信号仅此时计入。
1218
+ export function computeReviewHints(tasks, opts) {
1219
+ var list = Array.isArray(tasks) ? tasks : []
1220
+ var alertActive = !!(opts && opts.qualityAlertActive)
1221
+ var out = {}
1222
+ for (var i = 0; i < list.length; i++) {
1223
+ var t = list[i]
1224
+ if (!t || !t.id) continue
1225
+ var reasons = []
1226
+ if (reviewWasRejected(t)) reasons.push('曾被 Verifier 驳回(验收判 reject)')
1227
+ if (reviewHasArbitration(t)) reasons.push('执行中上报过歧义(待主窗口裁决)')
1228
+ if (reviewTouchesCore(t.touches)) reasons.push('touches 触碰核心文件(dispatch/rpc/core)')
1229
+ if ((t.pipeline || 'full') === 'full' && !(t.acceptance && String(t.acceptance).trim())) reasons.push('全流程任务缺硬性验收脚本(acceptance)')
1230
+ if (alertActive) {
1231
+ if (reviewDiffLines(t) > REVIEW_DIFF_LINES) reasons.push('改动规模大(diff 超 ' + REVIEW_DIFF_LINES + ' 行)')
1232
+ if (reviewResumeCount(t) >= REVIEW_RESUME_MIN) reasons.push('续跑 ≥' + REVIEW_RESUME_MIN + ' 次(冷复活重试偏多)')
1233
+ }
1234
+ out[t.id] = { score: reasons.length, reasons: reasons }
1235
+ }
1236
+ return out
1237
+ }
package/lib/dispatch.mjs CHANGED
@@ -7,12 +7,12 @@ 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, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
10
+ const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, 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):派发侧不再读盘——
14
14
  // 清单只给「路径:L行号 — 一句用途」,文件内容由 Worker 自己用 read 工具按行号范围自取。
15
- var rt = deps.rt, wt = deps.wt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
15
+ var rt = deps.rt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
16
16
  var rootForSession = deps.rootForSession, withTimeout = deps.withTimeout, runsFor = deps.runsFor, feedbackOn = deps.feedbackOn
17
17
  // 会话日志根(可选注入):usage 增量结算(卡3)必须「真读真日志」才测得出重复计账——
18
18
  // 生产不注入(usage.mjs 回退 os.homedir()),单测注入临时目录(否则只能开子进程改 HOME,
@@ -22,6 +22,7 @@ export function createDispatch(ctx, state, deps) {
22
22
  // Team 提示词组装是同步函数,只能读缓存,不能读盘。
23
23
  var epicSplitOn = deps.epicSplitOn
24
24
  var pushSysNote = deps.pushSysNote, maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
25
+ var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
25
26
  // 派发即回执:spawn 成功后入 45s 聚合队列的「🚀 已派发」区(老 host 未注入 → 静默跳过)
26
27
  var notifyDispatched = deps.notifyDispatched
27
28
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
@@ -805,7 +806,22 @@ export function createDispatch(ctx, state, deps) {
805
806
  doneOn = cfg(d).notifyDone !== false
806
807
  var t = d.tasks.find(function (x) { return x.id === rec.taskId })
807
808
  if (!t) return null
808
- if (t.status !== 'verifying' || t.escalation) return { task: t, already: true } // 工具通道已处理
809
+ if (t.escalation) return { task: t, already: true }
810
+ if (t.status !== 'verifying') {
811
+ // 迟到落账(验收时序三洞 ③):verifier 结论落到已落定卡(主窗口抢批/归档早于本 run 结算)不再静默丢弃。
812
+ // 但「工具通道已处理」= board_verdict 已由本 run 自己落账(t.verification.by === rec.id)→ 保持 already。
813
+ if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
814
+ if (t.verification && String(t.verification.by) === String(rec.id)) return { task: t, already: true }
815
+ var ltrim = (output || '').trim()
816
+ var lvm = ltrim.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
817
+ if (failed || !lvm) return { task: t, already: true } // 无有效结论,不落 late
818
+ var lapproved = lvm[1].toUpperCase() === 'APPROVED'
819
+ var lsecs = parseSections(ltrim)
820
+ var lr = lateVerdictApply(t, lapproved ? 'approved' : 'rejected', lsecs.verifySummary || ltrim.slice(0, 600), lsecs.checks || '', lsecs.userTest, new Date().toISOString(), String(rec.id), cfg(d).verifyUserGuide)
821
+ return { task: t, late: true, lateRejected: lr.lateRejected && lr.wasApproved }
822
+ }
823
+ return { task: t, already: true }
824
+ }
809
825
  var trimmed = (output || '').trim()
810
826
  var vm = trimmed.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
811
827
  if (failed || !vm) {
@@ -841,6 +857,14 @@ export function createDispatch(ctx, state, deps) {
841
857
  if (!result) return
842
858
  // already=true:工具通道(board_verdict)已推进状态并已发回执,settle 只负责 dispose,不再重复通知
843
859
  if (result.already) return
860
+ // 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知 + healthHint
861
+ if (result.late) {
862
+ if (result.lateRejected) {
863
+ if (notifyLateReject) notifyLateReject(sid, result.task)
864
+ poolHealthFor(sid).lateRejectNote = { taskId: result.task.id, at: Date.now() }
865
+ }
866
+ return
867
+ }
844
868
  if (result.escalated) maybeNotify(sid, result.task)
845
869
  if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
846
870
  if (doneOn && result.task && result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
@@ -1090,7 +1114,7 @@ export function createDispatch(ctx, state, deps) {
1090
1114
  try { await reconcileRoot(sid) } catch (e) { console.error('[task-board] reconcile 失败:', String(e)) }
1091
1115
  // 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
1092
1116
  // 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
1093
- // 故有回收时重读一次快照——否则下面空闲快进的 wt(snap) 会拿旧快照把收尾结果覆盖回去。
1117
+ // 故有回收时重读一次快照——否则下面空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(写盘已归一进锁)。
1094
1118
  var reapedN = 0
1095
1119
  try { reapedN = await reapGhostRecs(sid, snap) } catch (e) { console.error('[task-board] 幽灵占位回收失败(本轮跳过,派发照常):', String(e)) }
1096
1120
  if (reapedN > 0) {
@@ -1113,8 +1137,20 @@ export function createDispatch(ctx, state, deps) {
1113
1137
  var stalePool = snap.poolStatus && (((snap.poolStatus.workers || []).length + (snap.poolStatus.verifiers || []).length) > 0)
1114
1138
  // dispatchInfo 是瞬时通知(90s TTL):空闲周期也要负责过期清理,否则永久残留
1115
1139
  var staleInfo = snap.dispatchInfo && (!snap.dispatchInfoAt || Date.now() - new Date(snap.dispatchInfoAt).getTime() > 90000)
1116
- if (staleInfo) { delete snap.dispatchInfo; delete snap.dispatchInfoAt }
1117
- if (stalePool || staleInfo) { if (stalePool) snap.poolStatus = emptyPool; try { await wt(sid, snap) } catch (_) {} }
1140
+ if (stalePool || staleInfo) {
1141
+ // 锁归一(P1 根修 task-mv1e17x9):空闲清理写盘必须走 mutateLocked,且过期判定在锁内重读重判——
1142
+ // 锁外读到的 stalePool/staleInfo 此刻可能已被并发写清掉,拿旧快照直写会把它覆盖回去(丢更新)。
1143
+ try {
1144
+ await mutateLocked(sid, function (d) {
1145
+ var sp2 = d.poolStatus && (((d.poolStatus.workers || []).length + (d.poolStatus.verifiers || []).length) > 0)
1146
+ var si2 = d.dispatchInfo && (!d.dispatchInfoAt || Date.now() - new Date(d.dispatchInfoAt).getTime() > 90000)
1147
+ if (!sp2 && !si2) return null // 锁内重判已无过期项 → 不写(避免覆盖并发写)
1148
+ if (sp2) d.poolStatus = emptyPool
1149
+ if (si2) { delete d.dispatchInfo; delete d.dispatchInfoAt }
1150
+ return true // 返回非空触发写盘
1151
+ }, true) // skipKick:本轮已是 poolCycle,无需再踢
1152
+ } catch (_) {}
1153
+ }
1118
1154
  return snap
1119
1155
  }
1120
1156
  var c = cfg(snap)
@@ -1350,5 +1386,5 @@ export function createDispatch(ctx, state, deps) {
1350
1386
  // 在并行 spawn 下存在认领错包的潜伏 bug,一并消灭。
1351
1387
  }
1352
1388
 
1353
- return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack }
1389
+ return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory }
1354
1390
  }
package/lib/health.mjs CHANGED
@@ -194,6 +194,28 @@ export function computeRuntimeHealthHints(board, rt, opts) {
194
194
  var bornAt = typeof rt.bornAt === 'number' ? rt.bornAt : now
195
195
  var tasks = board.tasks
196
196
 
197
+ // --- ④ 看板数据文件腐坏/抢救可见化(task-mv0bl9vg ②③)---
198
+ // store.recoverCorrupt 隔离/抢救时写 rt.corruptNote({ at, salvage, kept, file }),这里现算透出
199
+ // err 级 hint(客户端红色档,区别于 warn/info)。数据安全红线信号:sticky 不消费、不 TTL——
200
+ // 只要板还处于腐坏态就持续亮(poolHealth 纯内存,host 重启自然清)。level='err' 是通道新档,
201
+ // 客户端按非 warn/info 渲染红色;老客户端缺省降级为 info 色,信号仍可见。
202
+ var cn = rt.corruptNote
203
+ if (cn && typeof cn === 'object') {
204
+ var cnText = cn.salvage
205
+ ? '🔴 看板数据文件腐坏,已自动抢救保留 ' + (cn.kept || 0) + ' 张卡(丢弃撕裂残片),原始文件留档 ' + (cn.file || '') + ',可联系恢复'
206
+ : '🔴 看板数据文件腐坏已隔离,历史在 ' + (cn.file || '') + ',可联系恢复'
207
+ out.hints.push({ level: 'err', text: cnText })
208
+ }
209
+
210
+ // --- ⑤ 迟到驳回可见化(验收时序三洞 ③,用户 n-mv0ebretgv1o)---
211
+ // 主窗口 force 抢批后 Verifier 迟到驳回:err 级 hint(sticky 不 TTL——host 重启才清),
212
+ // 与 corruptNote 同款「进行中的事故优先」语义。rt.lateRejectNote 由 board_verdict/settleVerifier
213
+ // 迟到落账路径写入(dispatch.mjs poolHealthFor / rpc.mjs state.poolHealth)。
214
+ var lr = rt.lateRejectNote
215
+ if (lr && typeof lr === 'object' && lr.taskId) {
216
+ out.hints.push({ level: 'err', text: '迟到驳回:' + String(lr.taskId) + ' 你批早了但 Verifier 验出红的' })
217
+ }
218
+
197
219
  if (alive) {
198
220
  // --- ① 派发循环心跳:有可派卡 + 池有空位 + 上次成功轮 >5min ---
199
221
  // poolLastOkAt 缺失时退到 bornAt(首次见到本板的时刻):host 重启后心跳全断也能在 5min 宽限后亮条,
package/lib/notify.mjs CHANGED
@@ -183,5 +183,27 @@ export function createNotify(ctx, state, deps) {
183
183
  } else deliver().catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
184
184
  }
185
185
 
186
- return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote }
186
+ // ===== 看板数据文件腐坏/抢救通知(task-mv0bl9vg ②):P0 数据安全,直投 owner,不走回执聚合 =====
187
+ // store.recoverCorrupt 隔离/抢救时调用(经 storeDeps.notifyBoardCorrupt 晚绑定注入)。
188
+ // 与歧义裁决通知同款:任何模式都投、不读回执开关、直接 followup——数据可能丢了必须立刻提醒,
189
+ // 不能等 45s 聚合窗口、更不能被「回执已关」吞掉(回执开关只闸派发/完成两条回执)。
190
+ function notifyBoardCorrupt(sid, text) {
191
+ var root = rootForSession(sid)
192
+ if (!root) return
193
+ try { root.followup(makeMsg('🚨 [任务看板] ' + text + '\n\n历史看板数据未丢,可从留档文件人工恢复;如需协助请告知。', 'notice', '看板数据文件腐坏')) } catch (e) { console.error('[task-board] board corrupt notify failed:', String(e)) }
194
+ }
195
+
196
+ // ===== 迟到驳回通知(验收时序三洞 ③,用户 n-mv0ebretgv1o):err 级,回执直投 owner,不走开关/聚合 =====
197
+ // 主窗口抢批(force approve)后 Verifier 迟到驳回——「你批早了但验收是红的」是 P1 信号,必须立刻直达
198
+ // owner:不进 45s 回执聚合、不被 notifyDone 开关吞掉(与 notifyBoardCorrupt 同一纪律)。
199
+ function notifyLateReject(sid, t) {
200
+ var root = rootForSession(sid)
201
+ if (!root) return
202
+ var lv = (t && t.lateVerdict) || {}
203
+ try {
204
+ root.followup(makeMsg('🚨 [任务看板] 迟到驳回:' + (t.id || '') + ' 你批早了但 Verifier 验出红的\n\n任务: ' + String(t.title || '') + ' (' + (t.id || '') + ')\n\nVerifier 结论(迟到): ' + String(lv.summary || '(无)') + '\n核对项: ' + String(lv.checks || '(无)') + '\n\n详情页已展示「迟到 Verifier 结论」块;如需据此返工,请把任务驳回重投或人工处置。', 'notice', '迟到驳回: ' + (t.id || '')))
205
+ } catch (e) { console.error('[task-board] late reject notify failed:', String(e)) }
206
+ }
207
+
208
+ return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote, notifyBoardCorrupt: notifyBoardCorrupt, notifyLateReject: notifyLateReject }
187
209
  }