dsh-agent-board 1.7.6 → 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.
@@ -9,7 +9,8 @@
9
9
  // · subagents.start(provider, req) → 一次性 run(result promise 由测试驱动落定/拒绝)
10
10
  // · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
11
11
  // · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
12
- // · agents.currentInitiator()/roots()/list()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root)
12
+ // · agents.currentInitiator()/roots()/list()/get()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root;env.setRoots 可清空模拟休眠)
13
+ // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话)
13
14
  // · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
14
15
  // · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
15
16
  // · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
@@ -48,6 +49,8 @@ import { Readable } from 'node:stream'
48
49
  * @property {Object} spSections systemPrompt 注册段记录器
49
50
  * @property {Object} llm llm 记录器(listProviders/listModels 调用留痕)
50
51
  * @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
52
+ * @property {(list:Object[])=>void} setRoots 改写活跃 root 集(传 [] 模拟休眠根会话/重建窗口)
53
+ * @property {Object} persistedHeaders 持久会话表 sid → { id, parentSession }(写 parentSession 模拟子会话;缺省根会话 parentSession=undefined)
51
54
  * @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
52
55
  * @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
53
56
  * @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
@@ -188,10 +191,14 @@ export function createMockCtx(opts) {
188
191
  // whenIdle 刻意不实现:flushReceipts 走「无空闲门控 → 立即投递」分支(45s 窗口到点即投,可断言)
189
192
  }
190
193
  var env // 前向引用(currentInitiator 读 env.actor)
194
+ var rootsList = [root] // 活跃 root 集(测试可 env.setRoots([]) 清空模拟「休眠根会话」)
195
+ var persistedHeaders = {} // 持久会话表 sid → SessionHeader(测试可写 parentSession 模拟子会话/根会话)
196
+ persistedHeaders[sid] = { id: sid, parentSession: undefined }
191
197
  var agents = {
192
198
  currentInitiator: function () { return { id: env.actor } },
193
- roots: function () { return [root] },
194
- list: function () { return [root] },
199
+ roots: function () { return rootsList },
200
+ list: function () { return rootsList.slice() },
201
+ get: function (id) { for (var i = 0; i < rootsList.length; i++) { if (String(rootsList[i].id) === String(id)) return rootsList[i] } return undefined },
195
202
  isOwnedBy: function () { return false },
196
203
  }
197
204
 
@@ -228,6 +235,11 @@ export function createMockCtx(opts) {
228
235
  listModels: async function (p) { llmCalls.listModels.push(p); return [] },
229
236
  }
230
237
  }
238
+ if (name === 'sessionPersistence') {
239
+ return {
240
+ stat: async function (id) { var h = persistedHeaders[id]; return h ? { header: h } : undefined },
241
+ }
242
+ }
231
243
  return undefined
232
244
  },
233
245
  effect: function (fn) {
@@ -260,6 +272,8 @@ export function createMockCtx(opts) {
260
272
  spSections: spSections,
261
273
  llm: llmCalls,
262
274
  actor: sid,
275
+ setRoots: function (list) { rootsList = list || [] }, // 清空/改写活跃 root 集(模拟休眠/重建窗口)
276
+ persistedHeaders: persistedHeaders, // 持久会话表(sid → { id, parentSession });写 parentSession 模拟子会话
263
277
 
264
278
  // 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
265
279
  rpc: async function (method, args) {
@@ -0,0 +1,170 @@
1
+ // dsh-agent-board — 风险队列「待你过目」+ 审视摘要区单测(test/review-hints.test.mjs,task-muzikj1y)
2
+ // 覆盖(对应任务断言①~⑥ + 接线):
3
+ // ① 四硬信号各命中/不命中(computeReviewHints:驳回/歧义/核心 touches/full 无 acceptance)
4
+ // ② 软信号平时不计 / 质量异动告警激活期间计入(diff>300 行 + 续跑≥2 次,联动加严)
5
+ // ③ reviewedAt 幂等落账(mark-reviewed RPC 只写一次)+ get-tasks 现算透出 reviewHint 零落库
6
+ // ④⑤ 客户端接线(徽章/chip/审视摘要区四块+来源徽标/digest 未过目)源码级 + 产物重组装
7
+ // ⑥ README 双份逐字一致 + 各补「待你过目」说明
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import * as core from '../lib/core.mjs'
12
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
13
+ import * as plugin from '../index.mjs'
14
+
15
+ // 纯函数测试的任务形态:默认「干净卡」(无驳回/无歧义/无核心 touches/有 acceptance)→ score 0
16
+ function mkTask(over) {
17
+ return Object.assign({ id: 't1', title: 'T', status: 'resolved', pipeline: 'full', acceptance: 'npm test', touches: [], messages: [], runs: [], rejectCount: 0 }, over || {})
18
+ }
19
+
20
+ // ===== ① 四硬信号各命中/不命中 =====
21
+ test('① 四硬信号各命中/不命中(computeReviewHints)', () => {
22
+ // 干净卡 → score 0
23
+ assert.deepEqual(core.computeReviewHints([mkTask()]), { t1: { score: 0, reasons: [] } })
24
+ // 硬① 被驳回过:rejectCount>0(canonical 口径)
25
+ var h1 = core.computeReviewHints([mkTask({ id: 'a', rejectCount: 1 })])
26
+ assert.equal(h1.a.score, 1); assert.match(h1.a.reasons[0], /驳回/)
27
+ // 硬① 备选:verification.verdict='rejected'
28
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', verification: { verdict: 'rejected' } })]).a.score, 1)
29
+ // 硬① 备选:messages kind='rejection'
30
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'rejection', text: 'x' }] })]).a.score, 1)
31
+ // 硬① 备选:runs 含 rejected 结局(老留档兼容,字面「runs 含 rejected」口径)
32
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', runs: [{ outcome: 'rejected' }] })]).a.score, 1)
33
+ // 硬① 不命中:零驳回
34
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', rejectCount: 0, verification: { verdict: 'approved' } })]).a.score, 0)
35
+ // 硬② 上报过歧义:messages kind='arbitration'
36
+ var h2 = core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'arbitration', text: 'x' }] })])
37
+ assert.equal(h2.a.score, 1); assert.match(h2.a.reasons[0], /歧义/)
38
+ // 硬② 不命中:无 arbitration 包
39
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'progress', text: 'x' }] })]).a.score, 0)
40
+ // 硬③ touches 碰核心文件:三种核心文件 basename 各命中
41
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['lib/dispatch.mjs'] })]).a.score, 1)
42
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['lib/rpc.mjs'] })]).a.score, 1)
43
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['core.mjs'] })]).a.score, 1)
44
+ assert.match(core.computeReviewHints([mkTask({ id: 'a', touches: ['./lib/core.mjs'] })]).a.reasons[0], /核心文件/)
45
+ // 硬③ 不命中:非核心文件 / glob
46
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['src/a.mjs'] })]).a.score, 0)
47
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['*.mjs'] })]).a.score, 0)
48
+ // 硬④ full 无 acceptance
49
+ var h4 = core.computeReviewHints([mkTask({ id: 'a', acceptance: '' })])
50
+ assert.equal(h4.a.score, 1); assert.match(h4.a.reasons[0], /验收脚本/)
51
+ // 硬④ 不命中:有 acceptance / work 档 / 空白 acceptance 但有内容(trim 后非空不算空)
52
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', acceptance: 'node --test' })]).a.score, 0)
53
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', acceptance: '', pipeline: 'work' })]).a.score, 0)
54
+ })
55
+
56
+ // ===== ② 软信号:平时不计 / 告警态计入(联动加严)=====
57
+ test('② 软信号:质量异动告警未激活不计入 / 激活期间计入(diff>300 行 + 续跑≥2 次)', () => {
58
+ var diff = ' src/a.mjs | 200 +++++++\n 1 file changed, 350 insertions(+), 50 deletions(-)'
59
+ var t = mkTask({ id: 'a', deliverable: { diff: diff }, runs: [{ role: 'worker', resume: true }, { role: 'worker', resume: true }] })
60
+ // 平时(告警未激活):软信号不计入 → score 0
61
+ assert.equal(core.computeReviewHints([t], {}).a.score, 0)
62
+ assert.equal(core.computeReviewHints([t], { qualityAlertActive: false }).a.score, 0)
63
+ // 告警激活:diff(400 行)>300 + 续跑 2 次≥2 → 两条软信号全计入
64
+ var h = core.computeReviewHints([t], { qualityAlertActive: true })
65
+ assert.equal(h.a.score, 2)
66
+ assert.match(h.a.reasons[0], /diff 超 300 行/)
67
+ assert.match(h.a.reasons[1], /续跑 ≥2 次/)
68
+ // 阈值边界:恰好 300 行不亮(>300 才亮);续跑 1 次不亮
69
+ var diffEdge = ' 1 file changed, 250 insertions(+), 50 deletions(-)' // 300 行
70
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', deliverable: { diff: diffEdge } })], { qualityAlertActive: true }).a.score, 0)
71
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', runs: [{ resume: true }] })], { qualityAlertActive: true }).a.score, 0)
72
+ // 硬软叠加:硬④ + 软⑤ + 软⑥ → score 3
73
+ var tMix = mkTask({ id: 'a', acceptance: '', deliverable: { diff: diff }, runs: [{ resume: true }, { resume: true }] })
74
+ var hMix = core.computeReviewHints([tMix], { qualityAlertActive: true })
75
+ assert.equal(hMix.a.score, 3)
76
+ })
77
+
78
+ // ===== ③ reviewedAt 幂等落账 + get-tasks 现算透出 reviewHint(真 harness)=====
79
+ test('③ mark-reviewed 幂等落账(只写一次)+ get-tasks 现算透出 reviewHint 零落库', async () => {
80
+ var env = createMockCtx()
81
+ try {
82
+ plugin.apply(env.ctx)
83
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-rv', title: 'R', description: 'd', pipeline: 'full', acceptance: 'npm test' })
84
+ assert.equal(env.task('task-rv').reviewedAt, undefined, '初始无 reviewedAt')
85
+ // 第一次落账
86
+ var r1 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'task-rv' })
87
+ assert.equal(r1.ok, true)
88
+ var at1 = env.task('task-rv').reviewedAt
89
+ assert.ok(at1, '第一次落账 reviewedAt')
90
+ // 幂等:第二次不重写
91
+ var r2 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'task-rv' })
92
+ assert.equal(r2.ok, true)
93
+ assert.equal(env.task('task-rv').reviewedAt, at1, '第二次调用不重写 reviewedAt')
94
+ // 未找到任务
95
+ var r3 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'nope' })
96
+ assert.equal(r3.ok, false)
97
+ // get-tasks 透出 reviewHint(现算,不落盘)
98
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-gh', title: 'G', description: 'd', pipeline: 'full', acceptance: 'npm test' })
99
+ env.patchBoard(function (d) {
100
+ var t = d.tasks.find(function (x) { return x.id === 'task-gh' })
101
+ t.rejectCount = 1
102
+ t.touches = ['lib/core.mjs']
103
+ t.acceptance = ''
104
+ })
105
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
106
+ var gh = d.tasks.find(function (x) { return x.id === 'task-gh' })
107
+ assert.ok(gh.reviewHint, 'get-tasks 透出 reviewHint')
108
+ assert.equal(gh.reviewHint.score, 3, '驳回 + 核心文件 + 无 acceptance = 3 条硬信号')
109
+ assert.equal(gh.reviewHint.reasons.length, 3)
110
+ // 零落库:board 文件不存 reviewHint(现算字段不进盘)
111
+ var raw = env.board().tasks.find(function (x) { return x.id === 'task-gh' })
112
+ assert.equal(raw.reviewHint, undefined, 'reviewHint 现算零落库(board 文件不写 reviewHint)')
113
+ } finally { await env.cleanup() }
114
+ })
115
+
116
+ // ===== ④⑤⑥ 客户端接线 + 产物重组装 + README 双份(源码级)=====
117
+ test('④⑤ 客户端接线:徽章/chip/审视摘要区四块+来源徽标/digest(源码级 + 产物重组装)', () => {
118
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
119
+ const bl = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
120
+ const td = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
121
+ const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
122
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
123
+ // kernel:单一事实源谓词 + 过滤接线 + state 默认字段
124
+ assert.match(ksrc, /function reviewPending\(t\)/)
125
+ assert.match(ksrc, /state\.filterReview && !reviewPending\(t\)/)
126
+ assert.match(ksrc, /filterReview: false/)
127
+ // board-list:chip「待你过目」+ 「建议过目」徽章
128
+ assert.ok(bl.indexOf('待你过目') >= 0, 'FilterBar 缺「待你过目」chip')
129
+ assert.ok(bl.indexOf('👁 建议过目') >= 0, '缺「建议过目」徽章')
130
+ assert.match(bl, /reviewPending\(t\) \?/)
131
+ // task-detail:审视摘要区四块 + 来源徽标 + mark-reviewed 触发
132
+ assert.ok(td.indexOf('tskb-review-summary') >= 0, '缺审视摘要区容器 id')
133
+ assert.ok(td.indexOf('① 为什么在这') >= 0, '缺四问①')
134
+ assert.ok(td.indexOf('② 改了什么') >= 0, '缺四问②')
135
+ assert.ok(td.indexOf('③ 机器怎么验的') >= 0, '缺四问③')
136
+ assert.ok(td.indexOf('④ 怎么亲自确认') >= 0, '缺四问④')
137
+ assert.ok(td.indexOf('Worker 汇报') >= 0, '缺「Worker 汇报」来源徽标')
138
+ assert.ok(td.indexOf('Verifier 实证') >= 0, '缺「Verifier 实证」来源徽标')
139
+ assert.ok(td.indexOf('系统记录') >= 0, '缺「系统记录」来源徽标')
140
+ assert.match(td, /rpc\('mark-reviewed'/)
141
+ // dashboard:digest 未过目计数 + buildReport 行
142
+ assert.match(dash, /reviewPending\(t\)/)
143
+ assert.ok(dash.indexOf('- 未过目:') >= 0, '报告缺「未过目」行')
144
+ // 产物重组装:关键标记逐一出现在 lib/client.js
145
+ for (const m of ['待你过目', '👁 建议过目', 'tskb-review-summary', '- 未过目:']) {
146
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
147
+ }
148
+ })
149
+
150
+ test('接线:core 纯函数 + rpc get-tasks 现算 + mark-reviewed(源码级)', () => {
151
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
152
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
153
+ assert.match(coreSrc, /export function computeReviewHints\(tasks, opts\)/)
154
+ assert.match(coreSrc, /export var REVIEW_CORE_FILES = \['dispatch\.mjs', 'rpc\.mjs', 'core\.mjs'\]/)
155
+ assert.match(rpc, /core\.computeReviewHints\(d\.tasks, \{ qualityAlertActive: __qch\.length > 0 \}\)/)
156
+ assert.match(rpc, /handle\('mark-reviewed'/)
157
+ assert.match(rpc, /if \(!t\.reviewedAt\) t\.reviewedAt = new Date\(\)\.toISOString\(\)/)
158
+ // tasksHash 序列化口径纳入 reviewedAt + reviewHint(已阅/软信号联动才能重渲染)
159
+ assert.match(coreSrc, /t\.reviewedAt,/)
160
+ assert.match(coreSrc, /t\.reviewHint \? String\(t\.reviewHint\.score\)/)
161
+ })
162
+
163
+ test('⑥ README 双份:逐字一致 + 各补「待你过目」说明', () => {
164
+ const r1 = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
165
+ const r2 = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
166
+ assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
167
+ assert.ok(r1.indexOf('待你过目') >= 0, 'README 缺「待你过目」说明')
168
+ assert.ok(r1.indexOf('审视摘要') >= 0, 'README 缺「审视摘要」说明')
169
+ assert.ok(r1.indexOf('reviewHint') >= 0, 'README 缺 reviewHint 说明')
170
+ })
@@ -0,0 +1,112 @@
1
+ // dsh-agent-board — 看板数据安全 P0 根修测试(task-mv0bl9vg)
2
+ // 三件套:① 原子写盘(tmp+fsync+rename+unlink 兜底)② 腐坏隔离可见化(err hint + notify)
3
+ // ③ 自动 salvage 截断抢救(JSON.parse 报错 position 截断法)。
4
+ // 全部跑在真实 index.mjs apply(mockCtx) 上;每个用例独立 mockCtx + 独立临时 HOME(HOME 是进程级,严禁并行)。
5
+ // 建卡后不推进虚拟时钟 → 不触发派发,无 worker spawn 噪声。
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
11
+ import * as plugin from '../index.mjs'
12
+
13
+ function boardFile(env) { return path.join(env.home, '.dsh', 'tasks-' + env.sid + '.json') }
14
+
15
+ // 建卡封装:显式 description 避开空描述软提示
16
+ async function createTask(env, id, extra) {
17
+ var args = { sessionId: env.sid, id: id, title: '数据安全卡 ' + id, description: '看板数据安全根修测试' }
18
+ if (extra) { for (var k in extra) args[k] = extra[k] }
19
+ var r = await env.rpc('create-task', args)
20
+ assert.equal(r.ok, true, 'create-task 成功: ' + id)
21
+ return r.task
22
+ }
23
+
24
+ // ===== ① 原子写:源码断言 + 行为级(rename 瞬断后旧文件不被撕裂)=====
25
+ test('① 原子写(源码级):store.mjs 写盘含 tmp+fsync+rename+unlink 兜底', () => {
26
+ var sto = fs.readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
27
+ assert.match(sto, /async function writeTmpFsync/) // fsync 专用写 tmp helper
28
+ assert.match(sto, /fh\.sync\(\)/) // fsync 落盘(先于 rename)
29
+ assert.match(sto, /fsNode\.promises\.rename\(tmp, p\)/) // tmp → rename 覆盖目标
30
+ assert.match(sto, /unlinkRetry\(p, 6\)/) // 直覆失败降级 unlink 兜底
31
+ assert.match(sto, /降级 unlink\+rename/) // 降级记录(不静默)
32
+ })
33
+
34
+ test('① 原子写(行为级):rename 瞬断(EBUSY 一次)→ 旧文件全程完整,退避重试后写回', async () => {
35
+ var env = createMockCtx()
36
+ plugin.apply(env.ctx)
37
+ var origRename = fs.promises.rename
38
+ try {
39
+ await createTask(env, 'a1')
40
+ var p = boardFile(env)
41
+ assert.equal(JSON.parse(fs.readFileSync(p, 'utf8')).tasks.length, 1, '初始一张卡,文件是完整 JSON')
42
+ // 注入一次瞬断:首次 rename 到主板路径时同步读盘取证 → 抛 EBUSY;之后恢复原样
43
+ var injected = false
44
+ var duringRename = null
45
+ fs.promises.rename = async function (from, to) {
46
+ if (!injected && String(to) === p) {
47
+ injected = true
48
+ duringRename = fs.readFileSync(p, 'utf8') // 瞬断时刻主板文件内容取证
49
+ var e = new Error('EBUSY injected'); e.code = 'EBUSY'; throw e
50
+ }
51
+ return origRename.call(fs.promises, from, to)
52
+ }
53
+ await createTask(env, 'a2')
54
+ var after = JSON.parse(fs.readFileSync(p, 'utf8'))
55
+ assert.equal(after.tasks.length, 2, '退避重试后写盘成功,最终内容完整(未撕裂)')
56
+ assert.ok(injected, '瞬断确实被注入')
57
+ assert.equal(JSON.parse(duringRename).tasks.length, 1, '瞬断时刻旧文件仍是完整旧 JSON(rename 原子,绝不半写)')
58
+ } finally {
59
+ fs.promises.rename = origRename
60
+ await env.cleanup()
61
+ }
62
+ })
63
+
64
+ // ===== ② 腐坏隔离:坏文件 → 隔离 .corrupt + err hint + notify(不再静默)=====
65
+ test('② 腐坏隔离:无法抢救的坏文件 → 隔离留档 + err hint + notify owner', async () => {
66
+ var env = createMockCtx()
67
+ plugin.apply(env.ctx)
68
+ try {
69
+ await createTask(env, 'b1')
70
+ var p = boardFile(env)
71
+ // 构造「无法抢救」的坏文件:单对象中部截断(无 position 可截 → salvage 失败 → 隔离)
72
+ fs.writeFileSync(p, '{"ownerSession":"' + env.sid + '","tasks":[{"id":"x"}', 'utf8')
73
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
74
+ assert.ok(Array.isArray(d.tasks) && d.tasks.length === 0, '隔离后空板重启')
75
+ var errHint = (d.healthHints || []).find(function (h) { return h && h.level === 'err' })
76
+ assert.ok(errHint, '隔离产出 err 级 hint')
77
+ assert.match(errHint.text, /腐坏已隔离/)
78
+ assert.match(errHint.text, /\.corrupt-/)
79
+ var corrupts = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return f.indexOf('.corrupt-') >= 0 })
80
+ assert.equal(corrupts.length, 1, '坏文件已改名 .corrupt-<ts> 留档')
81
+ var msg = env.sent.find(function (m) { return m && m.content && /腐坏已隔离/.test(m.content[0].text) })
82
+ assert.ok(msg, 'notify 通知 owner 会话(不再静默)')
83
+ } finally { await env.cleanup() }
84
+ })
85
+
86
+ // ===== ③ 自动 salvage:完整 JSON + 撕裂残片 → 截断抢救出第一段完整板 =====
87
+ test('③ 自动 salvage:完整 JSON 尾部夹杂撕裂残片 → 截断抢救出第一段完整板并写回', async () => {
88
+ var env = createMockCtx()
89
+ plugin.apply(env.ctx)
90
+ try {
91
+ await createTask(env, 'c1')
92
+ await createTask(env, 'c2')
93
+ await createTask(env, 'c3')
94
+ var p = boardFile(env)
95
+ var good = fs.readFileSync(p, 'utf8')
96
+ assert.equal(JSON.parse(good).tasks.length, 3, '前置:三张卡落盘')
97
+ // 构造真实腐坏样本形态:完整 JSON 尾部夹杂「另一版本残片」(未闭合的第二个 JSON 值)
98
+ var torn = good + '{"ownerSession":"' + env.sid + '","tasks":[{"id":"c4","title":"撕裂残片","status":"pend'
99
+ fs.writeFileSync(p, torn, 'utf8')
100
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
101
+ assert.equal(d.tasks.length, 3, '抢救出完整第一段(3 张卡),丢弃撕裂残片')
102
+ var after = JSON.parse(fs.readFileSync(p, 'utf8'))
103
+ assert.equal(after.tasks.length, 3, '抢救结果已原子写回主板文件(不再反复 poison)')
104
+ var errHint = (d.healthHints || []).find(function (h) { return h && h.level === 'err' })
105
+ assert.ok(errHint, '抢救产出 err 级 hint')
106
+ assert.match(errHint.text, /抢救保留 3 张卡/)
107
+ var corrupts = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return f.indexOf('.corrupt-') >= 0 })
108
+ assert.equal(corrupts.length, 1, '原始撕裂文件已留档 .corrupt-<ts>')
109
+ var msg = env.sent.find(function (m) { return m && m.content && /抢救/.test(m.content[0].text) })
110
+ assert.ok(msg, 'notify 通知 owner(抢救型)')
111
+ } finally { await env.cleanup() }
112
+ })
@@ -0,0 +1,266 @@
1
+ // dsh-agent-board — 验收时序三洞根修(用户 n-mv0ebretgv1o):task_verify 抢跑守卫 + 归档门禁查活跃 run + 迟到 verdict 落账
2
+ // 事故:主窗口在 Verifier 活跃期 task_verify 抢批(早 0.4s)+ 归档,Verifier 的 verdict/自测指南被静默吞掉、无回执。
3
+ // 三洞逐一锁:① 抢跑守卫(force 通道)② 归档门禁查活跃 run ③ 迟到 verdict 落 lateVerdict/回执/userTest 补挂。
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { createRpc } from '../lib/rpc.mjs'
7
+ import { createDispatch } from '../lib/dispatch.mjs'
8
+ import * as core from '../lib/core.mjs'
9
+
10
+ const FULL_SID = 'session-mock-timing-00000000-0001'
11
+
12
+ 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 || {}) }
13
+ function mkBoard(tasks) { return { version: 11, ownerSession: FULL_SID, boardMode: 'auto', verifyUserGuide: true, tasks: tasks || [] } }
14
+
15
+ function waitFor(fn, label, ms) {
16
+ var t0 = Date.now(); var cap = ms || 2000
17
+ var p = function () { return new Promise(function (r) { setTimeout(r, 5) }) }
18
+ return (async function () {
19
+ for (;;) {
20
+ var v; try { v = await fn() } catch (_) { v = null }
21
+ if (v) return v
22
+ if (Date.now() - t0 > cap) throw new Error('waitFor 超时(' + cap + 'ms): ' + (label || '(未命名)'))
23
+ await p()
24
+ }
25
+ })()
26
+ }
27
+
28
+ // ===== RPC/工具直调 harness(board_verdict / task_verify / archive-task / batch-op 三洞行为)=====
29
+ function mkRpcHarness(board, runsTable, extra) {
30
+ const state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {}, poolHealth: {} }
31
+ const tools = {}
32
+ const lateRejects = []
33
+ const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
34
+ const deps = Object.assign({
35
+ getActorId: () => 'tester', resolveRoot: (x) => x,
36
+ toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
37
+ rootForSession: () => null, deriveWorkMode: () => 'solo',
38
+ runsFor: (sid) => (runsTable[sid] || {}),
39
+ rt: async () => board, mutateLocked: (sid, fn) => fn(board),
40
+ maybeNotify: () => {}, notifyTaskDone: () => {},
41
+ spawnOneShot: () => {}, accumulateRunUsage: () => {}, readContextPack: async () => null,
42
+ pushSysNote: () => {}, sessionCwd: () => '',
43
+ hasActiveRun: () => false,
44
+ notifyLateReject: (sid, t) => { lateRejects.push({ sid: sid, id: t.id }) },
45
+ }, extra || {})
46
+ createRpc(ctx, state, deps)
47
+ state.handlers.__tools = tools
48
+ state.handlers.__lateRejects = lateRejects
49
+ state.handlers.__state = state
50
+ return state.handlers
51
+ }
52
+
53
+ // ===== dispatch harness(settleVerifier 文本通道迟到落账)=====
54
+ function mkSettleHarness(board) {
55
+ var runs = {}
56
+ var resolveRun = null
57
+ var boardRef = { b: board }
58
+ var lateRejects = []
59
+ var state = { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {}, poolHealth: {} }
60
+ var ctx = {
61
+ fs: {}, effect: function () {}, get: function () { return null }, timer: null,
62
+ subagents: {
63
+ list: function () { return ['mock'] },
64
+ getProvider: function () { return { inheritsParentContext: false } },
65
+ start: async function () { return { id: 'run-v1', result: new Promise(function (res) { resolveRun = res }), dispose: async function () {} } },
66
+ },
67
+ }
68
+ var dispatch = createDispatch(ctx, state, {
69
+ rt: async function () { return boardRef.b }, wt: async function () {}, mutateLocked: async function (sid, fn) { return fn(boardRef.b) },
70
+ kickCycle: function () {}, rootForSession: function () { return { id: FULL_SID } }, sessionCwd: function () { return '' },
71
+ withTimeout: function (p) { return p }, runsFor: function () { return runs }, feedbackOn: function () { return true }, epicSplitOn: function () { return true },
72
+ pushSysNote: function () {}, maybeNotify: function () {}, notifyTaskDone: function () {},
73
+ notifyLateReject: function (sid, t) { lateRejects.push(t.id) },
74
+ })
75
+ return { dispatch: dispatch, state: state, lateRejects: lateRejects, settle: function (text) { resolveRun({ output: [{ type: 'text', text: text }], stopReason: 'completed' }) } }
76
+ }
77
+
78
+ // ===== ① task_verify / verify-task 抢跑守卫 =====
79
+ test('① 抢跑守卫:verifier 活跃 → task_verify 工具 + verify-task RPC 拦截;force:true 放行;已 settled 不拦', async () => {
80
+ // RPC 通道:活跃 verifier rec → 拦截
81
+ const board = mkBoard([mkTask({ id: 'v1', status: 'verifying' })])
82
+ const runs = { [FULL_SID]: { 'v1': { id: 'run-v1', role: 'verifier', settled: false } } }
83
+ const h = mkRpcHarness(board, runs)
84
+ const r = await h['verify-task']({ taskId: 'v1', verdict: 'approved' })
85
+ assert.equal(r.ok, false)
86
+ assert.match(r.error, /Verifier 还在跑(run run-v1)/); assert.match(r.error, /force:true/)
87
+ assert.equal(board.tasks[0].status, 'verifying') // 未放行,状态不动
88
+ // RPC 通道:force:true → 放行
89
+ const r2 = await h['verify-task']({ taskId: 'v1', verdict: 'approved', force: true })
90
+ assert.equal(r2.ok, true); assert.equal(board.tasks[0].status, 'resolved')
91
+ // 工具通道同口径
92
+ const board2 = mkBoard([mkTask({ id: 'v2', status: 'verifying' })])
93
+ const runs2 = { [FULL_SID]: { 'v2': { id: 'run-v2', role: 'verifier', settled: false } } }
94
+ const h2 = mkRpcHarness(board2, runs2)
95
+ const r3 = await h2.__tools['task_verify'].execute({ taskId: 'v2', verdict: 'approved' }, {})
96
+ assert.equal(r3.ok, false); assert.match(r3.error, /Verifier 还在跑(run run-v2)/); assert.equal(r3.verifierActive, 'run-v2')
97
+ const r4 = await h2.__tools['task_verify'].execute({ taskId: 'v2', verdict: 'approved', force: true }, {})
98
+ assert.equal(r4.ok, true); assert.equal(board2.tasks[0].status, 'resolved')
99
+ // 已 settled 的 rec(Verifier 已结束)→ 不拦截
100
+ const board3 = mkBoard([mkTask({ id: 'v3', status: 'verifying' })])
101
+ const runs3 = { [FULL_SID]: { 'v3': { id: 'run-v3', role: 'verifier', settled: true } } }
102
+ const h3 = mkRpcHarness(board3, runs3)
103
+ const r5 = await h3['verify-task']({ taskId: 'v3', verdict: 'approved' })
104
+ assert.equal(r5.ok, true)
105
+ })
106
+
107
+ // ===== ② 归档门禁查活跃 run =====
108
+ test('② 归档门禁:resolved 卡有活跃 verifier run(内存 rec / 卡面 running 留档)→ 拒绝;无活跃 run 放行', async () => {
109
+ // 内存活跃表(最实时)
110
+ const board = mkBoard([mkTask({ id: 'a1', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z', verifiedBy: 'tester', verifierRun: 'run-v' })])
111
+ const runs = { [FULL_SID]: { 'a1': { id: 'run-v', role: 'verifier', settled: false } } }
112
+ const h = mkRpcHarness(board, runs)
113
+ const r = await h['archive-task']({ taskId: 'a1' })
114
+ assert.equal(r.ok, false)
115
+ assert.match(r.error, /有活跃 run(verifier run-v 仍在跑)/); assert.match(r.error, /等它落定或先 terminate/)
116
+ assert.equal(board.tasks[0].status, 'resolved') // 未归档
117
+ // 卡面 runs 留档兜底(无内存 rec,但 outcome running 且无 endedAt 且 id 匹配 verifierRun)
118
+ const board2 = mkBoard([mkTask({ id: 'a2', status: 'resolved', verifiedAt: 'x', verifierRun: 'run-v2', runs: [{ role: 'verifier', id: 'run-v2', outcome: 'running' }] })])
119
+ const h2 = mkRpcHarness(board2, {})
120
+ const r2 = await h2['archive-task']({ taskId: 'a2' })
121
+ assert.equal(r2.ok, false); assert.match(r2.error, /有活跃 run(verifier run-v2 仍在跑)/)
122
+ // resolved 无活跃 run → 放行(既有行为不破)
123
+ const board3 = mkBoard([mkTask({ id: 'a3', status: 'resolved' })])
124
+ const h3 = mkRpcHarness(board3, {})
125
+ const r3 = await h3['archive-task']({ taskId: 'a3' })
126
+ assert.equal(r3.ok, true); assert.equal(board3.tasks[0].status, 'archived')
127
+ // batch-op archive 同门禁
128
+ const board4 = mkBoard([mkTask({ id: 'b1', status: 'resolved', verifierRun: 'run-b', runs: [{ role: 'verifier', id: 'run-b', outcome: 'running' }] }), mkTask({ id: 'b2', status: 'resolved' })])
129
+ const h4 = mkRpcHarness(board4, {})
130
+ const r4 = await h4['batch-op']({ op: 'archive', ids: ['b1', 'b2'] })
131
+ assert.equal(r4.ok, true); assert.equal(r4.done, 1)
132
+ assert.deepEqual(r4.skipped, ['b1']); assert.match(r4.reasons['b1'], /有活跃 run/)
133
+ })
134
+
135
+ // ===== ② 归档门禁 × terminate 语义死结回归(Verifier 驳回回归根修)=====
136
+ test('② 回归:terminate verifier → verifierRun 认领位清空,归档不再误判活跃(此前永久无法归档死结)', async () => {
137
+ const board = mkBoard([mkTask({ id: 'a4', status: 'verifying', verifierRun: 'run-vX', runs: [{ role: 'verifier', id: 'run-vX', outcome: 'running' }] })])
138
+ const runs = { [FULL_SID]: { 'a4': { id: 'run-vX', role: 'verifier', settled: false } } }
139
+ const h = mkRpcHarness(board, runs)
140
+ // 终止:内存表摘除 + 认领位清空(与 in-progress 清 claimedBy 同口径)
141
+ const r = await h['terminate-agent']({ taskId: 'a4' })
142
+ assert.equal(r.ok, true); assert.equal(r.terminated, 'verifier:a4')
143
+ assert.equal(board.tasks[0].verifierRun, null)
144
+ assert.equal(board.tasks[0].verifierRunAt, undefined)
145
+ // 终止后主窗口补批(无需 force:已无活跃 verifier)→ resolved
146
+ const r2 = await h.__tools['task_verify'].execute({ taskId: 'a4', verdict: 'approved' }, {})
147
+ assert.equal(r2.ok, true); assert.equal(board.tasks[0].status, 'resolved')
148
+ // 归档:修复前这里会因卡面 run-vX 仍 running 而误报「有活跃 run」并永久拒绝
149
+ const r3 = await h['archive-task']({ taskId: 'a4' })
150
+ assert.equal(r3.ok, true); assert.equal(board.tasks[0].status, 'archived')
151
+ })
152
+
153
+ test('② 回归:无活跃 rec 的 stale verifying 卡(terminate 空转场景)→ terminate 仍清 verifierRun,解除死结', async () => {
154
+ const board = mkBoard([mkTask({ id: 'a5', status: 'verifying', verifierRun: 'run-vX', runs: [{ role: 'verifier', id: 'run-vX', outcome: 'running' }] })])
155
+ const h = mkRpcHarness(board, {}) // 内存表空:rec 已不在(重启/已摘),正是报错文案推荐「先 terminate」却空转的场景
156
+ const r = await h['terminate-agent']({ taskId: 'a5' })
157
+ assert.equal(r.ok, true); assert.equal(r.terminated, 'no-active-run')
158
+ assert.equal(board.tasks[0].verifierRun, null) // 认领位仍被清空 → 卡面兜底不再误判
159
+ const r2 = await h.__tools['task_verify'].execute({ taskId: 'a5', verdict: 'approved' }, {})
160
+ assert.equal(r2.ok, true); assert.equal(board.tasks[0].status, 'resolved')
161
+ const r3 = await h['archive-task']({ taskId: 'a5' })
162
+ assert.equal(r3.ok, true); assert.equal(board.tasks[0].status, 'archived')
163
+ })
164
+
165
+ // ===== ③④⑤ 迟到 verdict 落账(工具通道 board_verdict)=====
166
+ test('③ 迟到 approved:board_verdict 落到已 resolved 卡 → 落 lateVerdict,不炸、状态不动', async () => {
167
+ const board = mkBoard([mkTask({ id: 'l1', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
168
+ const h = mkRpcHarness(board, {})
169
+ const r = await h.__tools['board_verdict'].execute({ taskId: 'l1', verdict: 'approved', summary: '复跑通过', checks: '## 核对项\n- ok' }, {})
170
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, false)
171
+ const t = board.tasks[0]
172
+ assert.equal(t.status, 'resolved') // 状态不动
173
+ assert.equal(t.lateVerdict.verdict, 'approved')
174
+ assert.equal(t.lateVerdict.summary, '复跑通过')
175
+ assert.match(t.lateVerdict.note, /迟到结论:卡在落定后收到/)
176
+ // 已落定卡上的 board_verdict 不触发正常回执/重派(返回体带 late 旗即可,无报错)
177
+ })
178
+
179
+ test('④ 迟到 rejected + 卡已 approved:board_verdict → err 通知 + healthHint(lateRejectNote)', async () => {
180
+ const board = mkBoard([mkTask({ id: 'l2', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
181
+ const h = mkRpcHarness(board, {})
182
+ const r = await h.__tools['board_verdict'].execute({ taskId: 'l2', verdict: 'rejected', summary: '单测没过', checks: '## 核对项\n- 红' }, {})
183
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, true)
184
+ const t = board.tasks[0]
185
+ assert.equal(t.lateVerdict.verdict, 'rejected')
186
+ // err 通知已投(notifyLateReject 捕获,直投 owner 不走回执聚合)
187
+ assert.equal(h.__lateRejects.length, 1); assert.equal(h.__lateRejects[0].id, 'l2')
188
+ // healthHint 落点(poolHealth.lateRejectNote,health.mjs computeRuntimeHealthHints 现算透出)
189
+ assert.equal(h.__state.poolHealth[FULL_SID].lateRejectNote.taskId, 'l2')
190
+ })
191
+
192
+ test('⑤ userTest 补挂:迟到结论带 userTest → 补挂 t.verification.userTest;开关关则不挂', async () => {
193
+ const ut = { gist: '改了验收时序', steps: ['打开看板'], expect: '看到提示', tier: 'ui' }
194
+ const board = mkBoard([mkTask({ id: 'l3', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
195
+ const h = mkRpcHarness(board, {})
196
+ await h.__tools['board_verdict'].execute({ taskId: 'l3', verdict: 'rejected', summary: 'x', userTest: ut }, {})
197
+ const t = board.tasks[0]
198
+ assert.ok(t.lateVerdict.userTest, 'lateVerdict 挂 userTest')
199
+ assert.ok(t.verification, '原 verification 缺 → 补建')
200
+ assert.equal(t.verification.userTest.gist, '改了验收时序')
201
+ assert.equal(t.verification.verdict, 'rejected') // 补建的 verification 用迟到结论的 verdict 兜底,非空
202
+ // 开关关 → 不挂 userTest、不补建 verification
203
+ const board2 = mkBoard([mkTask({ id: 'l4', status: 'resolved', verifiedAt: 'x' })]); board2.verifyUserGuide = false
204
+ const h2 = mkRpcHarness(board2, {})
205
+ await h2.__tools['board_verdict'].execute({ taskId: 'l4', verdict: 'rejected', summary: 'x', userTest: ut }, {})
206
+ const t2 = board2.tasks[0]
207
+ assert.equal(t2.lateVerdict.userTest, undefined)
208
+ assert.equal(t2.verification, undefined)
209
+ })
210
+
211
+ // ===== ③④ 迟到 verdict 落账(文本通道 settleVerifier)=====
212
+ test('③④ settleVerifier 迟到:主窗口抢批 resolved 后 Verifier 迟到 REJECTED → 落 lateVerdict + 通知 + healthHint', async () => {
213
+ const t = mkTask({ id: 's1', status: 'verifying' })
214
+ const board = mkBoard([t])
215
+ const h = mkSettleHarness(board)
216
+ await h.dispatch.poolCycle(FULL_SID)
217
+ assert.equal(t.verifierRun, 'run-v1') // 已派 Verifier
218
+ // 主窗口抢批(早于 verifier 结算):直接把卡置 resolved(等价 task_verify force approved,不写 t.verification)
219
+ t.status = 'resolved'; t.verifiedAt = '2026-01-01T00:01:00Z'; t.verifiedBy = 'tester'
220
+ h.settle('REJECTED: 交付物与验收脚本不符\n\n## 测试概要\nnpm test 未跑通\n\n## 核对项\n- 断言①红')
221
+ await waitFor(function () { return t.lateVerdict }, 'settleVerifier 迟到落 lateVerdict')
222
+ assert.equal(t.lateVerdict.verdict, 'rejected')
223
+ assert.match(t.lateVerdict.summary, /npm test 未跑通/)
224
+ assert.match(t.lateVerdict.checks, /断言①红/)
225
+ assert.match(t.lateVerdict.note, /迟到结论/)
226
+ // err 通知 + healthHint
227
+ assert.equal(h.lateRejects.length, 1); assert.equal(h.lateRejects[0], 's1')
228
+ assert.equal(h.state.poolHealth[FULL_SID].lateRejectNote.taskId, 's1')
229
+ // 状态不被迟到的驳回改写(仍 resolved,不再回 pending 重派)
230
+ assert.equal(t.status, 'resolved')
231
+ })
232
+
233
+ test('settleVerifier 迟到落账:工具通道已落账(verification.by === rec.id)→ 不重复落 lateVerdict', async () => {
234
+ const t = mkTask({ id: 's2', status: 'verifying' })
235
+ const board = mkBoard([t])
236
+ const h = mkSettleHarness(board)
237
+ await h.dispatch.poolCycle(FULL_SID)
238
+ assert.equal(t.verifierRun, 'run-v1')
239
+ // 模拟 board_verdict 工具通道已落账(by = 本 run 的 rec.id)并推进到 resolved
240
+ t.verification = { verdict: 'approved', summary: 'OK', checks: '', at: '2026-01-01T00:01:00Z', by: 'run-v1' }
241
+ t.status = 'resolved'; t.verifiedAt = '2026-01-01T00:01:00Z'
242
+ h.settle('APPROVED: ok')
243
+ await new Promise(function (r) { setTimeout(r, 50) }) // 给结算链一点时间跑完,确认不落 lateVerdict
244
+ assert.equal(t.lateVerdict, undefined)
245
+ assert.equal(t.verification.verdict, 'approved') // 原结论不被覆盖
246
+ })
247
+
248
+ // ===== 纯函数口径:lateVerdictApply wasApproved 判定 =====
249
+ test('lateVerdictApply:wasApproved 口径(resolved / archived-from-resolved / archived-from-cancelled)', () => {
250
+ // resolved → approved
251
+ const t1 = mkTask({ id: 'p1', status: 'resolved', verifiedAt: 'x' })
252
+ assert.equal(core.lateVerdictApply(t1, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, true)
253
+ // archived 且带 verifiedAt → approved
254
+ const t2 = mkTask({ id: 'p2', status: 'archived', verifiedAt: 'x' })
255
+ assert.equal(core.lateVerdictApply(t2, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, true)
256
+ // archived 无 approved 痕迹(从 cancelled 归档)→ 非 approved
257
+ const t3 = mkTask({ id: 'p3', status: 'archived' })
258
+ assert.equal(core.lateVerdictApply(t3, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, false)
259
+ // cancelled → 非 approved
260
+ const t4 = mkTask({ id: 'p4', status: 'cancelled' })
261
+ assert.equal(core.lateVerdictApply(t4, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, false)
262
+ // approved verdict → lateRejected false
263
+ const t5 = mkTask({ id: 'p5', status: 'resolved', verifiedAt: 'x' })
264
+ const r5 = core.lateVerdictApply(t5, 'approved', 's', 'c', null, 'at', 'by', true)
265
+ assert.equal(r5.lateRejected, false); assert.equal(r5.wasApproved, true)
266
+ })