dsh-agent-board 1.8.1 → 1.8.2

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/README.md CHANGED
@@ -113,11 +113,11 @@ dsh plugin --profile web remove dsh-agent-board
113
113
 
114
114
  ## 功能总览
115
115
 
116
- **核心能力一页速览**(`★` 为 v1.7.5–1.8.1 新栈):
116
+ **核心能力一页速览**(`★` 为 v1.7.5–1.8.2 新栈):
117
117
 
118
118
  - **看板**:六列状态流、拖拽 / 多选批量 / 文本·优先级·标签筛选、归档留档、会话隔离
119
119
  - **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管、**spawn 落闸锁内重验**(spawnGateOk:claim 占位与落闸决策读同一把锁的同一份快照,terminate/dependsOn 后补 落在 spawn 在途窗口即弃权——竞态与后补依赖双双根治)、**高优干预分态**(intervene 无活跃 run 时区分「续跑窗口,干预随续跑注入」与「真无 run」,返回体带 state 字段)、**建卡派发预警**(将自动派发时 lintWarnings 瞬态提示「约 1s 内被领取,想占位请 draft:true」)
120
- - **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+迟到驳回直投告警,不再静默吞掉)
120
+ - **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+直投告警)、**迟到 verdict 三保险**(①入参校验:`verdict` 非 approved/rejected 拒收并返回重调指引——漏带字段不再被当驳回落账;②通知分态:approved 迟到=「✅ 迟到复核·全过追认」notice 级,🚨「迟到驳回」只留给真 rejected;③指纹去重:同 taskId+by+verdict 只投一次,重复落账零重复通知)
121
121
  - **度量** `★`:Token 消耗(总量 / 有效 / 缓存读,按模型·按任务·按日)+ 记分卡(模型表现 / 质量趋势 / 驳回聚类)
122
122
  - **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)、**僵尸 run 留档清扫**(host 重启后「落定卡上的 running 无 endedAt 留档」在派发周期自动关账 `stale-closed` + 清认领位,归档门禁不再被幻影 run 永久拦截)、**子会话生命周期收口**(一次性 run 落定/迟到 verdict 落账后主动 interrupt 子会话——「结算≠停止」空转根治;continuable 收紧为「续跑位真在本 child 手上」才豁免,残影 Worker 无处藏身)、**terminate 三保险**(no-rec 也按认领位盲杀补刀 + interrupt 失败 err 级可见化 + `hold:true` 原子「终止+转手动」)、**配置审计**(set-board-config 成功写入落板级 configLog {key,from,to,actor,at} 截尾 50 条;模型下拉两段式选中≠保存)
123
123
  - **学习反馈**:候选教训信号 → 主窗口沉淀(零耦合,不碰任何笔记 / 记忆工具 API)
package/index.mjs CHANGED
@@ -71,6 +71,7 @@ export function apply(ctx) {
71
71
  withTimeout: session.withTimeout, runsFor: session.runsFor, feedbackOn: session.feedbackOn, epicSplitOn: session.epicSplitOn,
72
72
  pushSysNote: notify.pushSysNote, maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
73
73
  notifyDispatched: notify.notifyDispatched, notifyLateReject: notify.notifyLateReject,
74
+ notifyLateApprove: notify.notifyLateApprove, // 迟到复核分态通知(三连根修②,task-mvf7nve8)
74
75
  })
75
76
  storeDeps.poolCycle = dispatch.poolCycle // 晚绑定收口
76
77
  // 僵尸 epic 出清配套(反馈 n-mutma3mmwceq):archive-task 门禁的活性判定。
@@ -82,6 +83,7 @@ export function apply(ctx) {
82
83
  rootForSession: session.rootForSession, deriveWorkMode: session.deriveWorkMode, runsFor: session.runsFor,
83
84
  rt: store.rt, mutateLocked: store.mutateLocked,
84
85
  maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone, notifyLateReject: notify.notifyLateReject,
86
+ notifyLateApprove: notify.notifyLateApprove, // 迟到复核分态通知(三连根修②,task-mvf7nve8)
85
87
  pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
86
88
  spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
87
89
  closeRunHistory: dispatch.closeRunHistory, // 手动终止 run 关账(doTerminate 状态无关清理,task-mv1pqy27)
package/lib/core.mjs CHANGED
@@ -602,6 +602,22 @@ export function pushRejection(t, summary, checks, at, by) {
602
602
  return true
603
603
  }
604
604
 
605
+ // ===== verdict 入参校验(迟到 verdict 三连根修①,task-mvf7nve8)=====
606
+ // 事故实证:Verifier 调 board_verdict 漏带 verdict 字段,undefined 直写 lateVerdict/verification,
607
+ // 被 lateRejected=!approved 当驳回 → err 级「迟到驳回」误报 + 重复投递。board_verdict 工具通道与
608
+ // late 落账双闸:缺失/非 'approved'|'rejected' 一律拒收(返回错误串),调用方不落账不通知——
609
+ // 模型拿到错误会带正确字段重调。主窗口 task_verify/verify-task 人工通道不经此闸(schema 已枚举约束)。
610
+ export function verdictErr(verdict) {
611
+ if (verdict === 'approved' || verdict === 'rejected') return null
612
+ return 'verdict 必须是 approved 或 rejected(收到:' + String(verdict) + '),请带正确字段重调'
613
+ }
614
+ // 迟到通知同指纹去重(三连根修③):指纹 = taskId+by+verdict,rpc/dispatch 两调用点共用,
615
+ // 存 poolHealth[sid].lastLateNotice(sticky 内存级,host 重启自然清)——同指纹二次落账零重复通知。
616
+ export function lateNoticeFp(t) {
617
+ var lv = (t && t.lateVerdict) || {}
618
+ return String((t && t.id) || '') + '|' + String(lv.by || '') + '|' + String(lv.verdict || '')
619
+ }
620
+
605
621
  // ===== 迟到 verdict 落账(验收时序三洞 ③,用户 n-mv0ebretgv1o)=====
606
622
  // 事故:主窗口在 Verifier 活跃期 task_verify 抢批(早 0.4s)+ 归档,Verifier 的独立结论与自测
607
623
  // 指南被静默吞掉、无回执。修法:verifier 结论(工具通道 board_verdict / 文本通道 settleVerifier)
@@ -612,6 +628,8 @@ export function pushRejection(t, summary, checks, at, by) {
612
628
  // 返回 { lateRejected, wasApproved }:调用方据此决定要不要发 err 级通知 + healthHint
613
629
  // (I/O 留在调用方,core 保持零依赖)。wasApproved 必须先算——下方可能覆写 t.verification。
614
630
  export function lateVerdictApply(t, verdict, summary, checks, userTest, at, by, verifyUserGuideOn) {
631
+ var vErr = verdictErr(verdict) // 三连根修①:非法 verdict 零落账(undefined 不再被当驳回)
632
+ if (vErr) return { ok: false, invalid: true, error: vErr, lateRejected: false, wasApproved: false }
615
633
  var approved = verdict === 'approved'
616
634
  var wasApproved = t.status === 'resolved' || (t.status === 'archived' && !!(t.verifiedAt || t.verifiedBy || (t.verification && t.verification.verdict === 'approved')))
617
635
  t.lateVerdict = { verdict: verdict, summary: summary || '', checks: checks || '', at: at || new Date().toISOString(), by: by || 'system', note: '迟到结论:卡在落定后收到' }
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, lateVerdictApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
10
+ const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, lateNoticeFp, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, depsSatisfied, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
11
11
 
12
12
  export function createDispatch(ctx, state, deps) {
13
13
  // 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
@@ -23,6 +23,7 @@ export function createDispatch(ctx, state, deps) {
23
23
  var epicSplitOn = deps.epicSplitOn
24
24
  var pushSysNote = deps.pushSysNote, maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
25
25
  var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
26
+ var notifyLateApprove = (typeof deps.notifyLateApprove === 'function') ? deps.notifyLateApprove : null // 迟到复核 notice 级通知(三连根修②,未接线 → 静默跳过)
26
27
  // 派发即回执:spawn 成功后入 45s 聚合队列的「🚀 已派发」区(老 host 未注入 → 静默跳过)
27
28
  var notifyDispatched = deps.notifyDispatched
28
29
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
@@ -997,6 +998,7 @@ export function createDispatch(ctx, state, deps) {
997
998
  var lapproved = lvm[1].toUpperCase() === 'APPROVED'
998
999
  var lsecs = parseSections(ltrim)
999
1000
  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)
1001
+ if (lr && lr.invalid) return { task: t, already: true } // 三连根修①:非法 verdict 零落账(防御——本路径 verdict 恒来自正则解析)
1000
1002
  return { task: t, late: true, lateRejected: lr.lateRejected && lr.wasApproved }
1001
1003
  }
1002
1004
  return { task: t, already: true }
@@ -1044,11 +1046,19 @@ export function createDispatch(ctx, state, deps) {
1044
1046
  if (!result) return
1045
1047
  // already=true:工具通道(board_verdict)已推进状态并已发回执,settle 只负责 dispose,不再重复通知
1046
1048
  if (result.already) return
1047
- // 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知 + healthHint
1049
+ // 迟到通知分态(三连根修②③,task-mvf7nve8):rejected 且卡已 approved → err 级「🚨 迟到驳回」+
1050
+ // healthHint(banner 只 rejected 亮);approved → notice 级「✅ 迟到复核:维持原判」。
1051
+ // 同指纹(taskId+by+verdict,poolHealth.lastLateNotice)已投过 → 跳过,零重复通知。
1048
1052
  if (result.late) {
1053
+ var __phL = poolHealthFor(sid)
1054
+ var __fpL = lateNoticeFp(result.task)
1055
+ var __onceL = __phL.lastLateNotice !== __fpL
1056
+ if (__onceL) __phL.lastLateNotice = __fpL
1049
1057
  if (result.lateRejected) {
1050
- if (notifyLateReject) notifyLateReject(sid, result.task)
1051
- poolHealthFor(sid).lateRejectNote = { taskId: result.task.id, at: Date.now() }
1058
+ __phL.lateRejectNote = { taskId: result.task.id, at: Date.now() } // banner 幂等 sticky,不受去重闸影响
1059
+ if (__onceL && notifyLateReject) notifyLateReject(sid, result.task)
1060
+ } else if (__onceL && notifyLateApprove && result.task.lateVerdict && result.task.lateVerdict.verdict === 'approved') {
1061
+ notifyLateApprove(sid, result.task)
1052
1062
  }
1053
1063
  return
1054
1064
  }
package/lib/health.mjs CHANGED
@@ -211,6 +211,8 @@ export function computeRuntimeHealthHints(board, rt, opts) {
211
211
  // 主窗口 force 抢批后 Verifier 迟到驳回:err 级 hint(sticky 不 TTL——host 重启才清),
212
212
  // 与 corruptNote 同款「进行中的事故优先」语义。rt.lateRejectNote 由 board_verdict/settleVerifier
213
213
  // 迟到落账路径写入(dispatch.mjs poolHealthFor / rpc.mjs state.poolHealth)。
214
+ // 只 rejected 亮(三连根修②⑤,task-mvf7nve8):approved 迟到结论走 notice 级「✅ 迟到复核」
215
+ // 直投,绝不写 lateRejectNote——err banner 不为「维持原判」误亮。
214
216
  var lr = rt.lateRejectNote
215
217
  if (lr && typeof lr === 'object' && lr.taskId) {
216
218
  out.hints.push({ level: 'err', text: '迟到驳回:' + String(lr.taskId) + ' 你批早了但 Verifier 验出红的' })
package/lib/notify.mjs CHANGED
@@ -205,5 +205,17 @@ export function createNotify(ctx, state, deps) {
205
205
  } catch (e) { console.error('[task-board] late reject notify failed:', String(e)) }
206
206
  }
207
207
 
208
- return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote, notifyBoardCorrupt: notifyBoardCorrupt, notifyLateReject: notifyLateReject }
208
+ // ===== 迟到复核通知(迟到 verdict 三连根修②,task-mvf7nve8):approved 迟到结论 → notice 级直投同通道 =====
209
+ // 与 notifyLateReject 同纪律(直投 owner、不走开关/聚合),但标题正文如实——「维持原判(全过追认)」,
210
+ // 不亮 err 级「迟到驳回」(此前 undefined verdict 让全过卡被记驳回的事故形态就此绝迹)。
211
+ function notifyLateApprove(sid, t) {
212
+ var root = rootForSession(sid)
213
+ if (!root) return
214
+ var lv = (t && t.lateVerdict) || {}
215
+ try {
216
+ root.followup(makeMsg('✅ [任务看板] 迟到复核:' + (t.id || '') + ' 维持原判(全过追认)\n\n任务: ' + String(t.title || '') + ' (' + (t.id || '') + ')\n\nVerifier 结论(迟到): ' + String(lv.summary || '(无)') + '\n核对项: ' + String(lv.checks || '(无)') + '\n\n卡已落定,本次迟到结论与之一致,仅留痕详情页「迟到 Verifier 结论」块。', 'notice', '迟到复核: ' + (t.id || '')))
217
+ } catch (e) { console.error('[task-board] late approve notify failed:', String(e)) }
218
+ }
219
+
220
+ return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote, notifyBoardCorrupt: notifyBoardCorrupt, notifyLateReject: notifyLateReject, notifyLateApprove: notifyLateApprove }
209
221
  }
package/lib/rpc.mjs CHANGED
@@ -14,7 +14,7 @@ import { makeMsg } from './notify.mjs'
14
14
  import { computeHealthHints, computeRuntimeHealthHints } from './health.mjs'
15
15
  import { isFullSessionId } from './session.mjs' // 幻影板防线口径(纯函数,与 policy.mjs 直引同例)
16
16
  import { previewCleanup, runCleanup, SKIP_REASON_TEXT } from './cleanup.mjs' // 已结算会话清理(预览+执行)
17
- const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, lateVerdictApply, archiveApply, cancelApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint, appendDispatchWarning } = core
17
+ const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, lateVerdictApply, verdictErr, lateNoticeFp, archiveApply, cancelApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint, appendDispatchWarning } = core
18
18
 
19
19
  function defineTool(options) {
20
20
  var userExecute = options.execute
@@ -84,6 +84,7 @@ export function createRpc(ctx, state, deps) {
84
84
  }
85
85
  var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
86
86
  var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
87
+ var notifyLateApprove = (typeof deps.notifyLateApprove === 'function') ? deps.notifyLateApprove : null // 迟到复核 notice 级通知(三连根修②,未接线 → 静默跳过)
87
88
  var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
88
89
  // 手动终止的 run 关账(outcome:'terminated',task-mv1pqy27 死结窗口根修):与状态无关,
89
90
  // 凡 terminate 命中该卡的 run 就关。未注入(老宿主/测试桩)→ 静默跳过,行为与改造前逐字一致。
@@ -131,6 +132,17 @@ export function createRpc(ctx, state, deps) {
131
132
  var ph = state.poolHealth[sid] || (state.poolHealth[sid] = { bornAt: Date.now() })
132
133
  ph.lateRejectNote = { taskId: taskId, at: Date.now() }
133
134
  }
135
+ // 迟到通知同指纹去重(三连根修③,task-mvf7nve8):指纹(taskId+by+verdict)记入
136
+ // poolHealth[sid].lastLateNotice(sticky 内存级),已投过返回 false——同一结论经 RPC+
137
+ // settle 兜底+模型重调多次落账时零重复通知。
138
+ function lateNoticeOnce(sid, t) {
139
+ if (!state.poolHealth) state.poolHealth = {}
140
+ var ph = state.poolHealth[sid] || (state.poolHealth[sid] = { bornAt: Date.now() })
141
+ var fp = lateNoticeFp(t)
142
+ if (ph.lastLateNotice === fp) return false
143
+ ph.lastLateNotice = fp
144
+ return true
145
+ }
134
146
  // 终止未生效 healthHint 落点(terminate 假杀根修②,task-mv8jfu1v,事故实证 n-mv8cfndmvfcv):
135
147
  // doTerminate 的 interrupt 抛错/无中断能力/补杀核验未过时写。与 lateRejectNote 同款 sticky——
136
148
  // 僵尸 Worker 继续写盘是进行中的数据安全事故,亮到人来处理(host 重启才清)。
@@ -911,6 +923,11 @@ export function createRpc(ctx, state, deps) {
911
923
  } }))
912
924
  ctx.tools.register(defineTool({ name: 'board_verdict', description: '[任务看板 Verifier 专用] 提交验收结论。verdict=approved/rejected;summary=测试概要;checks=逐条核对证据。userTest 可选:自测指南四字段 {gist=一句人话, steps=[用户操作步骤], expect=预期看到什么, tier=ui|metric|internal}(板级开关 verifyUserGuide 开启时随结论一并提交;tier=internal 时 steps 可空、expect 写「验证靠测试套件」)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, summary: { type: 'string' }, checks: { type: 'string' }, userTest: { type: 'object', properties: { gist: { type: 'string' }, steps: { type: 'array', items: { type: 'string' } }, expect: { type: 'string' }, tier: { type: 'string', enum: ['ui', 'metric', 'internal'] } } } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) {
913
925
  var sid = toolSessionId(); var actor = getActorId()
926
+ // 入参校验(三连根修①,task-mvf7nve8):verdict 缺失/非法 → 拒收带指引,不落账不通知
927
+ // (实证:Verifier 漏带 verdict,undefined 落账被 lateRejected 当驳回 → err 误报+重复投递)。
928
+ // 只闸本工具的 verifier 通道与 late 分支;主窗口 task_verify/verify-task 人工通道不受此限。
929
+ var __vErr = verdictErr(args.verdict)
930
+ if (__vErr) return { ok: false, error: __vErr }
914
931
  var approved = args.verdict === 'approved'
915
932
  var result = await mutateLocked(sid, function (d) {
916
933
  var t = d.tasks.find(function (x) { return x.id === args.taskId })
@@ -948,10 +965,17 @@ export function createRpc(ctx, state, deps) {
948
965
  var bt = result.task
949
966
  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) }
950
967
  }
951
- // 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知(回执直投 owner,不走开关)+ healthHint
952
- if (result && result.ok && result.late && result.lateRejected) {
953
- if (notifyLateReject) notifyLateReject(sid, result.task)
954
- markLateRejectHealth(sid, result.task.id)
968
+ // 迟到通知分态(三连根修②③,task-mvf7nve8):verdict=rejected 且卡已 approved → err 级
969
+ // 「🚨 迟到驳回」直投 + healthHint(banner 只 rejected 亮);verdict=approved → notice 级
970
+ // 「✅ 迟到复核:维持原判(全过追认)」直投同通道。同指纹(taskId+by+verdict)已投过 → 跳过。
971
+ if (result && result.ok && result.late) {
972
+ var __once = lateNoticeOnce(sid, result.task)
973
+ if (result.lateRejected) {
974
+ markLateRejectHealth(sid, result.task.id) // banner 幂等 sticky,不受去重闸影响
975
+ if (__once && notifyLateReject) notifyLateReject(sid, result.task)
976
+ } else if (__once && notifyLateApprove && result.task.lateVerdict && result.task.lateVerdict.verdict === 'approved') {
977
+ notifyLateApprove(sid, result.task)
978
+ }
955
979
  }
956
980
  // ===== 结算≠停止根修③(task-mv8wuznb,实证:verifier 迟到交付 verdict 落账后又空转 8h)=====
957
981
  // 迟到落账时该 verifier 的活跃 rec 通常早已被幽灵回收(卡落定即失活),settleRunRecord 的补停
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.8.1",
4
+ "version": "1.8.2",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
@@ -63,7 +63,7 @@
63
63
  },
64
64
  "peerDependencies": {
65
65
  "react": ">=18",
66
- "@deepseek-ai/dsh": "^0.1.7 || 0.2.0-rc.2 || ^0.2.0"
66
+ "@deepseek-ai/dsh": "^0.1.7 || 0.2.0-rc.2 || 0.2.1-rc.2 || ^0.2.0"
67
67
  },
68
68
  "license": "MIT"
69
69
  }
@@ -0,0 +1,144 @@
1
+ // dsh-agent-board — 迟到 verdict 三连根修(task-mvf7nve8)
2
+ // 事故实证:Verifier 调 board_verdict 漏带 verdict 字段(undefined 落账),被 lateRejected=!approved
3
+ // 当驳回 → err 级「迟到驳回」误报 + 同一结论多次落账重复投递(J2 task-mvf2lpzk 4 次 / H2 task-mveljjwd 3 次)。
4
+ // 三连逐一锁:① 入参校验(缺/非法 verdict 拒收带指引+零落账)② 通知分态(approved=✅迟到复核 /
5
+ // rejected=🚨迟到驳回,banner 只 rejected 亮)③ 同指纹(taskId+by+verdict)去重零重复通知。
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { createRpc } from '../lib/rpc.mjs'
9
+ import * as core from '../lib/core.mjs'
10
+ import { createNotify } from '../lib/notify.mjs'
11
+ import { computeRuntimeHealthHints } from '../lib/health.mjs'
12
+
13
+ const FULL_SID = 'session-mock-trio-00000000-0001'
14
+
15
+ function mkTask(over) { return Object.assign({ id: 't1', title: 'T', description: '', status: 'pending', priority: 'medium', tags: [], parentId: null, assignMode: 'auto', assignee: null, context: { instructions: '' }, acceptance: '', dependsOn: [], pipeline: 'full', claimedBy: null, claimedAt: null, createdAt: '2026-01-01T00:00:00Z', resolvedAt: null, history: [], messages: [] }, over || {}) }
16
+ function mkBoard(tasks) { return { version: 11, ownerSession: FULL_SID, boardMode: 'auto', verifyUserGuide: true, tasks: tasks || [] } }
17
+
18
+ // RPC/工具直调 harness(与 verify-timing.test.mjs 同款,加 notifyLateApprove 捕获)
19
+ function mkHarness(board, extra) {
20
+ const state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {}, poolHealth: {} }
21
+ const tools = {}
22
+ const calls = { lateRejects: [], lateApproves: [] }
23
+ const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
24
+ const deps = Object.assign({
25
+ getActorId: () => 'verifier-child-1', resolveRoot: (x) => x,
26
+ toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
27
+ rootForSession: () => null, deriveWorkMode: () => 'solo',
28
+ runsFor: () => ({}),
29
+ rt: async () => board, mutateLocked: (sid, fn) => fn(board),
30
+ maybeNotify: () => {}, notifyTaskDone: () => {},
31
+ spawnOneShot: () => {}, accumulateRunUsage: () => {}, readContextPack: async () => null,
32
+ pushSysNote: () => {}, sessionCwd: () => '',
33
+ hasActiveRun: () => false,
34
+ notifyLateReject: (sid, t) => { calls.lateRejects.push(t.id) },
35
+ notifyLateApprove: (sid, t) => { calls.lateApproves.push(t.id) },
36
+ }, extra || {})
37
+ createRpc(ctx, state, deps)
38
+ return { tools: tools, calls: calls, state: state }
39
+ }
40
+
41
+ // ===== ① 入参校验:缺 verdict / 非法值 → 拒收带指引 + 零落账零通知 =====
42
+ test('① board_verdict 缺 verdict / 非法值:拒收带指引,不落账不通知(verifying 卡与已落定卡同口径)', async () => {
43
+ // verifying 卡:缺 verdict
44
+ const b1 = mkBoard([mkTask({ id: 'k1', status: 'verifying' })])
45
+ const h1 = mkHarness(b1)
46
+ const r1 = await h1.tools['board_verdict'].execute({ taskId: 'k1', summary: '全过' }, {})
47
+ assert.equal(r1.ok, false)
48
+ assert.match(r1.error, /verdict 必须是 approved 或 rejected(收到:undefined),请带正确字段重调/)
49
+ assert.equal(b1.tasks[0].status, 'verifying', '状态不动')
50
+ assert.equal(b1.tasks[0].verification, undefined, '零落账:无 verification')
51
+ assert.equal(b1.tasks[0].lateVerdict, undefined, '零落账:无 lateVerdict')
52
+ // 已落定卡:非法值 verdict → 同样拒收(迟到分支也被闸)
53
+ const b2 = mkBoard([mkTask({ id: 'k2', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
54
+ const h2 = mkHarness(b2)
55
+ const r2 = await h2.tools['board_verdict'].execute({ taskId: 'k2', verdict: 'pass', summary: '全过' }, {})
56
+ assert.equal(r2.ok, false)
57
+ assert.match(r2.error, /verdict 必须是 approved 或 rejected(收到:pass)/)
58
+ assert.equal(b2.tasks[0].lateVerdict, undefined, '零落账:非法 verdict 不落 lateVerdict')
59
+ assert.equal(b2.tasks[0].verification, undefined, '零落账:不补建 verification')
60
+ assert.deepEqual(h2.calls.lateRejects, [], '零通知:无迟到驳回')
61
+ assert.deepEqual(h2.calls.lateApproves, [], '零通知:无迟到复核')
62
+ assert.equal(h2.state.poolHealth[FULL_SID], undefined, '零通知:去重指纹也未写')
63
+ // 纯函数闸:lateVerdictApply 非法 verdict 直返 invalid,一字不落
64
+ const t3 = mkTask({ id: 'k3', status: 'resolved' })
65
+ const r3 = core.lateVerdictApply(t3, undefined, 's', 'c', null, 'at', 'by', true)
66
+ assert.equal(r3.invalid, true); assert.match(r3.error, /收到:undefined/)
67
+ assert.equal(t3.lateVerdict, undefined); assert.equal(t3.verification, undefined)
68
+ // 人工通道不受此限:task_verify 正常走(schema 枚举之外的运行时不拦主窗口)
69
+ const b4 = mkBoard([mkTask({ id: 'k4', status: 'verifying' })])
70
+ const h4 = mkHarness(b4)
71
+ const r4 = await h4.tools['task_verify'].execute({ taskId: 'k4', verdict: 'approved' }, {})
72
+ assert.equal(r4.ok, true); assert.equal(b4.tasks[0].status, 'resolved')
73
+ })
74
+
75
+ // ===== ② approved 迟到:notice 级「✅ 迟到复核」通知,非 err 级「🚨 迟到驳回」=====
76
+ test('② approved 迟到:通知走 notifyLateApprove(✅ 迟到复核),不触 notifyLateReject,无 lateRejectNote', async () => {
77
+ const board = mkBoard([mkTask({ id: 'a1', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
78
+ const h = mkHarness(board)
79
+ const r = await h.tools['board_verdict'].execute({ taskId: 'a1', verdict: 'approved', summary: '复跑全过', checks: '## 核对项\n- ok' }, {})
80
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, false)
81
+ assert.equal(board.tasks[0].lateVerdict.verdict, 'approved')
82
+ assert.deepEqual(h.calls.lateApproves, ['a1'], 'approved 迟到 → ✅ 迟到复核通知')
83
+ assert.deepEqual(h.calls.lateRejects, [], 'approved 迟到绝不触 🚨 迟到驳回')
84
+ assert.equal(h.state.poolHealth[FULL_SID].lateRejectNote, undefined, 'health banner 不为 approved 亮')
85
+ // notify 本体文案:✅ 迟到复核 · 维持原判(全过追认),非 err 措辞
86
+ const sent = []
87
+ const notify = createNotify({}, {}, { rt: async () => board, rootForSession: () => ({ followup: (m) => sent.push(m) }), withTimeout: (p) => p, isDispatched: () => false })
88
+ notify.notifyLateApprove(FULL_SID, board.tasks[0])
89
+ assert.equal(sent.length, 1)
90
+ const txt = sent[0].content[0].text
91
+ assert.match(txt, /✅ \[任务看板\] 迟到复核:a1 维持原判(全过追认)/)
92
+ assert.match(txt, /复跑全过/)
93
+ assert.ok(!/🚨|迟到驳回/.test(txt), 'approved 通知无 err 级措辞')
94
+ assert.equal(sent[0].source.summary, '迟到复核: a1')
95
+ })
96
+
97
+ // ===== ③ 同指纹去重:二次落账零重复通知 =====
98
+ test('③ 同指纹(taskId+by+verdict)二次落账:通知只投一次;不同 verdict 指纹不同 → 各投一次', async () => {
99
+ const board = mkBoard([mkTask({ id: 'd1', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
100
+ const h = mkHarness(board)
101
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'approved', summary: '第一次' }, {})
102
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'approved', summary: '第二次(同指纹重投)' }, {})
103
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'approved', summary: '第三次(同指纹重投)' }, {})
104
+ assert.deepEqual(h.calls.lateApproves, ['d1'], '同指纹三次落账只投一次通知')
105
+ assert.equal(h.state.poolHealth[FULL_SID].lastLateNotice, 'd1|verifier-child-1|approved', '指纹 = taskId+by+verdict')
106
+ // 指纹变(verdict 翻驳回)→ 新通知照投(reject 通道)
107
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'rejected', summary: '翻驳回' }, {})
108
+ assert.deepEqual(h.calls.lateRejects, ['d1'], '指纹不同 → rejected 通知照投')
109
+ // 驳回同指纹重投 → 也去重
110
+ await h.tools['board_verdict'].execute({ taskId: 'd1', verdict: 'rejected', summary: '驳回重投' }, {})
111
+ assert.deepEqual(h.calls.lateRejects, ['d1'], 'rejected 同指纹重投零重复')
112
+ })
113
+
114
+ // ===== ④ rejected 迟到不回归:维持 err「🚨 迟到驳回」+ lateRejectNote =====
115
+ test('④ rejected 迟到(卡已 approved):维持 err 级「🚨 迟到驳回」通知 + healthHint 不回归', async () => {
116
+ const board = mkBoard([mkTask({ id: 'r1', status: 'resolved', resolvedAt: '2026-01-01T00:01:00Z' })])
117
+ const h = mkHarness(board)
118
+ const r = await h.tools['board_verdict'].execute({ taskId: 'r1', verdict: 'rejected', summary: '单测红', checks: '## 核对项\n- 红' }, {})
119
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, true)
120
+ assert.deepEqual(h.calls.lateRejects, ['r1'])
121
+ assert.deepEqual(h.calls.lateApproves, [], 'rejected 绝不触 ✅ 迟到复核')
122
+ assert.equal(h.state.poolHealth[FULL_SID].lateRejectNote.taskId, 'r1', 'err banner 数据源照写')
123
+ // notify 本体文案:🚨 迟到驳回
124
+ const sent = []
125
+ const notify = createNotify({}, {}, { rt: async () => board, rootForSession: () => ({ followup: (m) => sent.push(m) }), withTimeout: (p) => p, isDispatched: () => false })
126
+ notify.notifyLateReject(FULL_SID, board.tasks[0])
127
+ assert.equal(sent.length, 1)
128
+ assert.match(sent[0].content[0].text, /🚨 \[任务看板\] 迟到驳回:r1 你批早了但 Verifier 验出红的/)
129
+ })
130
+
131
+ // ===== ⑤ health banner 只 rejected 亮 =====
132
+ test('⑤ health err banner:lateRejectNote 只在 rejected 路径出现,computeRuntimeHealthHints 如实透出', () => {
133
+ // 无 lateRejectNote(approved 迟到的盘面)→ 无迟到 banner
134
+ const b1 = mkBoard([mkTask({ id: 'h1', status: 'resolved', lateVerdict: { verdict: 'approved', by: 'v', at: 'x' } })])
135
+ const o1 = computeRuntimeHealthHints(b1, { bornAt: Date.now(), lastLateNotice: 'h1|v|approved' }, { capW: 0, alive: false })
136
+ assert.ok(!o1.hints.some(function (x) { return /迟到/.test(x.text) }), 'approved 迟到盘面零迟到 banner')
137
+ // 有 lateRejectNote(rejected 迟到)→ err 级 banner
138
+ const b2 = mkBoard([mkTask({ id: 'h2', status: 'resolved' })])
139
+ const o2 = computeRuntimeHealthHints(b2, { bornAt: Date.now(), lateRejectNote: { taskId: 'h2', at: Date.now() } }, { capW: 0, alive: false })
140
+ const hit = o2.hints.find(function (x) { return /迟到驳回/.test(x.text) })
141
+ assert.ok(hit, 'rejected 迟到 → banner 亮')
142
+ assert.equal(hit.level, 'err')
143
+ assert.match(hit.text, /迟到驳回:h2 你批早了但 Verifier 验出红的/)
144
+ })