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.
@@ -8,6 +8,7 @@
8
8
  // ③ 空态——整区空态灰字(锚 id 照挂)+ 单块空态灰字;无样本日画灰基线不假装 0%
9
9
  // ④ caption 口径一句——看板派发 run 本体 + 驳回定义(验收 verdict rejected)
10
10
  // ⑤ 接线与产物——Dashboard 在 ModelPerf 正下方挂载 QualityTrends;client.js 已重组装;README 双份同步
11
+ // ⑥ 重叠回归锁(task-mv11xd0o)——柱图容器 minWidth/overflow 三件套 + 柱高 clamp 22px(防单日 100% 柱越界盖右列)
11
12
  import { test } from 'node:test'
12
13
  import assert from 'node:assert/strict'
13
14
  import { readFileSync } from 'node:fs'
@@ -102,3 +103,23 @@ test('卡4⑤:接线——Dashboard 挂载在 ModelPerf 正下方;产物已
102
103
  assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
103
104
  assert.ok(r1.indexOf('质量趋势') >= 0, 'README 仪表盘段缺「质量趋势」区说明')
104
105
  })
106
+
107
+ // ===== ⑥ 重叠回归锁(task-mv11xd0o):柱图容器 minWidth/overflow + 柱高 clamp =====
108
+ // 缺陷复盘:范围收窄到单日 + 当日 100% 时,一次通过率黑柱向上越界盖住右侧「卡时长分布」面板标题/条形。
109
+ // 锁法(源码级硬断言,防回归重跑):① 柱图容器 minWidth:0 + overflow:hidden(flex 项不越界三件套)
110
+ // ② 柱高 clamp 到 22px(46px 绘图容器扣上下率值/日期标签与 gap/描边后不越出)
111
+ test('卡4⑥:重叠回归锁——柱图容器 minWidth/overflow 三件套 + 柱高 clamp 22px', () => {
112
+ // ① 柱图容器锁:qtDayBars 容器带 minWidth:0 + overflow:hidden(flex 项不越界三件套)
113
+ const bars = sliceFn(src, 'qtDayBars')
114
+ assert.ok(bars.indexOf('height: 46, minWidth: 0, overflow: \'hidden\'') >= 0, 'qtDayBars 容器须带 minWidth:0 + overflow:hidden(flex 不越界三件套)')
115
+ // ① 柱体 cell 锁:flex:1 项必须 minWidth:0 才能收缩到容器内(否则 min-content 撑破容器越界)
116
+ assert.ok(bars.indexOf('flex: 1, minWidth: 0') >= 0, '柱体 cell 须带 minWidth:0(flex:1 项可收缩)')
117
+ // ① 左右分栏锁:block1(一次通过率)/ block3(超时率)左列容器 overflow:hidden 防越界盖右列
118
+ const blockLocks = src.split('flex: \'1 1 260px\', minWidth: 230, overflow: \'hidden\'').length - 1
119
+ assert.ok(blockLocks >= 2, 'block1/block3 左列容器须均带 overflow:hidden(命中 ' + blockLocks + ' 处,应 ≥2)')
120
+ // ② 柱高 clamp:100% 柱封顶 22px(防单日 100% 黑柱越出绘图容器)
121
+ assert.ok(src.indexOf('h = Math.min(22, h)') >= 0, '柱高须 clamp 到 22px(防单日 100% 黑柱越界)')
122
+ // 产物重组装:client.js 同源标记须存在(npm run build-client 已重组装)
123
+ assert.ok(built.indexOf('height: 46, minWidth: 0, overflow: \'hidden\'') >= 0, 'client.js 缺容器锁标记(需 npm run build-client 重组装)')
124
+ assert.ok(built.indexOf('h = Math.min(22, h)') >= 0, 'client.js 缺柱高 clamp 标记(需 npm run build-client 重组装)')
125
+ })
@@ -0,0 +1,217 @@
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 os from 'node:os'
10
+ import path from 'node:path'
11
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
12
+ import * as plugin from '../index.mjs'
13
+ import { createStore } from '../lib/store.mjs'
14
+
15
+ function boardFile(env) { return path.join(env.home, '.dsh', 'tasks-' + env.sid + '.json') }
16
+
17
+ // 建卡封装:显式 description 避开空描述软提示
18
+ async function createTask(env, id, extra) {
19
+ var args = { sessionId: env.sid, id: id, title: '数据安全卡 ' + id, description: '看板数据安全根修测试' }
20
+ if (extra) { for (var k in extra) args[k] = extra[k] }
21
+ var r = await env.rpc('create-task', args)
22
+ assert.equal(r.ok, true, 'create-task 成功: ' + id)
23
+ return r.task
24
+ }
25
+
26
+ // ===== ① 原子写:源码断言 + 行为级(rename 瞬断后旧文件不被撕裂)=====
27
+ test('① 原子写(源码级):store.mjs 写盘含 tmp+fsync+rename+unlink 兜底', () => {
28
+ var sto = fs.readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
29
+ assert.match(sto, /async function writeTmpFsync/) // fsync 专用写 tmp helper
30
+ assert.match(sto, /fh\.sync\(\)/) // fsync 落盘(先于 rename)
31
+ assert.match(sto, /fsNode\.promises\.rename\(tmp, p\)/) // tmp → rename 覆盖目标
32
+ assert.match(sto, /unlinkRetry\(p, 6\)/) // 直覆失败降级 unlink 兜底
33
+ assert.match(sto, /降级 unlink\+rename/) // 降级记录(不静默)
34
+ })
35
+
36
+ test('① 原子写(行为级):rename 瞬断(EBUSY 一次)→ 旧文件全程完整,退避重试后写回', async () => {
37
+ var env = createMockCtx()
38
+ plugin.apply(env.ctx)
39
+ var origRename = fs.promises.rename
40
+ try {
41
+ await createTask(env, 'a1')
42
+ var p = boardFile(env)
43
+ assert.equal(JSON.parse(fs.readFileSync(p, 'utf8')).tasks.length, 1, '初始一张卡,文件是完整 JSON')
44
+ // 注入一次瞬断:首次 rename 到主板路径时同步读盘取证 → 抛 EBUSY;之后恢复原样
45
+ var injected = false
46
+ var duringRename = null
47
+ fs.promises.rename = async function (from, to) {
48
+ if (!injected && String(to) === p) {
49
+ injected = true
50
+ duringRename = fs.readFileSync(p, 'utf8') // 瞬断时刻主板文件内容取证
51
+ var e = new Error('EBUSY injected'); e.code = 'EBUSY'; throw e
52
+ }
53
+ return origRename.call(fs.promises, from, to)
54
+ }
55
+ await createTask(env, 'a2')
56
+ var after = JSON.parse(fs.readFileSync(p, 'utf8'))
57
+ assert.equal(after.tasks.length, 2, '退避重试后写盘成功,最终内容完整(未撕裂)')
58
+ assert.ok(injected, '瞬断确实被注入')
59
+ assert.equal(JSON.parse(duringRename).tasks.length, 1, '瞬断时刻旧文件仍是完整旧 JSON(rename 原子,绝不半写)')
60
+ } finally {
61
+ fs.promises.rename = origRename
62
+ await env.cleanup()
63
+ }
64
+ })
65
+
66
+ // ===== ② 腐坏隔离:坏文件 → 隔离 .corrupt + err hint + notify(不再静默)=====
67
+ test('② 腐坏隔离:无法抢救的坏文件 → 隔离留档 + err hint + notify owner', async () => {
68
+ var env = createMockCtx()
69
+ plugin.apply(env.ctx)
70
+ try {
71
+ await createTask(env, 'b1')
72
+ var p = boardFile(env)
73
+ // 构造「无法抢救」的坏文件:单对象中部截断(无 position 可截 → salvage 失败 → 隔离)
74
+ fs.writeFileSync(p, '{"ownerSession":"' + env.sid + '","tasks":[{"id":"x"}', 'utf8')
75
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
76
+ assert.ok(Array.isArray(d.tasks) && d.tasks.length === 0, '隔离后空板重启')
77
+ var errHint = (d.healthHints || []).find(function (h) { return h && h.level === 'err' })
78
+ assert.ok(errHint, '隔离产出 err 级 hint')
79
+ assert.match(errHint.text, /腐坏已隔离/)
80
+ assert.match(errHint.text, /\.corrupt-/)
81
+ var corrupts = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return f.indexOf('.corrupt-') >= 0 })
82
+ assert.equal(corrupts.length, 1, '坏文件已改名 .corrupt-<ts> 留档')
83
+ var msg = env.sent.find(function (m) { return m && m.content && /腐坏已隔离/.test(m.content[0].text) })
84
+ assert.ok(msg, 'notify 通知 owner 会话(不再静默)')
85
+ } finally { await env.cleanup() }
86
+ })
87
+
88
+ // ===== ③ 自动 salvage:完整 JSON + 撕裂残片 → 截断抢救出第一段完整板 =====
89
+ test('③ 自动 salvage:完整 JSON 尾部夹杂撕裂残片 → 截断抢救出第一段完整板并写回', async () => {
90
+ var env = createMockCtx()
91
+ plugin.apply(env.ctx)
92
+ try {
93
+ await createTask(env, 'c1')
94
+ await createTask(env, 'c2')
95
+ await createTask(env, 'c3')
96
+ var p = boardFile(env)
97
+ var good = fs.readFileSync(p, 'utf8')
98
+ assert.equal(JSON.parse(good).tasks.length, 3, '前置:三张卡落盘')
99
+ // 构造真实腐坏样本形态:完整 JSON 尾部夹杂「另一版本残片」(未闭合的第二个 JSON 值)
100
+ var torn = good + '{"ownerSession":"' + env.sid + '","tasks":[{"id":"c4","title":"撕裂残片","status":"pend'
101
+ fs.writeFileSync(p, torn, 'utf8')
102
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
103
+ assert.equal(d.tasks.length, 3, '抢救出完整第一段(3 张卡),丢弃撕裂残片')
104
+ var after = JSON.parse(fs.readFileSync(p, 'utf8'))
105
+ assert.equal(after.tasks.length, 3, '抢救结果已原子写回主板文件(不再反复 poison)')
106
+ var errHint = (d.healthHints || []).find(function (h) { return h && h.level === 'err' })
107
+ assert.ok(errHint, '抢救产出 err 级 hint')
108
+ assert.match(errHint.text, /抢救保留 3 张卡/)
109
+ var corrupts = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return f.indexOf('.corrupt-') >= 0 })
110
+ assert.equal(corrupts.length, 1, '原始撕裂文件已留档 .corrupt-<ts>')
111
+ var msg = env.sent.find(function (m) { return m && m.content && /抢救/.test(m.content[0].text) })
112
+ assert.ok(msg, 'notify 通知 owner(抢救型)')
113
+ } finally { await env.cleanup() }
114
+ })
115
+
116
+ // ===== ④ P1 根修(task-mv1e17x9):tmp 唯一名 + 四条锁外写路径归一(原子写时代撕裂根修)=====
117
+
118
+ // 直连 store 层的最小环境:只测 wt() 原子写 / tmp 残件清理,不拉起整个插件。
119
+ // 独立接管 process.env.HOME(与 createMockCtx 同款纪律:HOME 是进程级,用例串行 + finally 恢复)。
120
+ function makeRawStore() {
121
+ var home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-board-wt-'))
122
+ fs.mkdirSync(path.join(home, '.dsh'), { recursive: true })
123
+ var prevHome = process.env.HOME
124
+ var hadHome = Object.prototype.hasOwnProperty.call(process.env, 'HOME')
125
+ process.env.HOME = home
126
+ var state = { fileLocks: {}, cyclePending: {}, poolHealth: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {} }
127
+ var store = createStore({}, state, { sessionCwd: function () { return home } })
128
+ return {
129
+ store: store, home: home,
130
+ cleanup: function () {
131
+ if (hadHome) process.env.HOME = prevHome
132
+ else delete process.env.HOME
133
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch (_) {}
134
+ },
135
+ }
136
+ }
137
+
138
+ test('④ tmp 唯一名(源码级):wt() 的 tmp 名 = pid + 时间戳 + 进程内自增序号', () => {
139
+ var sto = fs.readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
140
+ assert.match(sto, /process\.pid/) // tmp 名含 pid(跨进程也唯一)
141
+ assert.match(sto, /Date\.now\(\)/) // 含时间戳
142
+ assert.match(sto, /\+\+tmpSeq/) // 含进程内自增序号(同 ms 并发也唯一)
143
+ assert.match(sto, /\+ '\.tmp'/) // 仍是 .tmp 后缀
144
+ assert.match(sto, /var tmpSeq = 0/) // 序号计数器声明(模块级,跨 store 实例共享)
145
+ })
146
+
147
+ test('④ tmp 唯一名(行为级):两个并发 3MB 写后文件必是其中一份完整 JSON,且无 .tmp 残件', async () => {
148
+ var env = makeRawStore()
149
+ var sid = 'session-wt-' + Date.now().toString(36)
150
+ try {
151
+ var fillerA = 'A'.repeat(3 * 1024 * 1024)
152
+ var fillerB = 'B'.repeat(3 * 1024 * 1024)
153
+ var dA = { ownerSession: sid, tasks: [], marker: 'A', filler: fillerA }
154
+ var dB = { ownerSession: sid, tasks: [], marker: 'B', filler: fillerB }
155
+ var a = JSON.stringify(dA), b = JSON.stringify(dB)
156
+ await Promise.all([env.store.wt(sid, dA), env.store.wt(sid, dB)])
157
+ var p = path.join(env.home, '.dsh', 'tasks-' + sid + '.json')
158
+ var final = fs.readFileSync(p, 'utf8')
159
+ assert.ok(final === a || final === b, '最终文件必是其中一份完整 JSON(不是拼接怪)')
160
+ var leftovers = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return /\.tmp$/.test(f) })
161
+ assert.equal(leftovers.length, 0, '成功写盘后无 .tmp 残件')
162
+ } finally { env.cleanup() }
163
+ })
164
+
165
+ test('④ 无 .tmp 残件(行为级):rename 非瞬时失败后清理自己的 tmp', async () => {
166
+ var env = makeRawStore()
167
+ var sid = 'session-wt-fail-' + Date.now().toString(36)
168
+ var origRename = fs.promises.rename
169
+ try {
170
+ var p = path.join(env.home, '.dsh', 'tasks-' + sid + '.json')
171
+ await env.store.wt(sid, { ownerSession: sid, tasks: [] }) // 先正常写一次
172
+ assert.ok(fs.existsSync(p), '前置:主板已落盘')
173
+ fs.promises.rename = async function () { var e = new Error('EIO injected'); e.code = 'EIO'; throw e }
174
+ try { await env.store.wt(sid, { ownerSession: sid, tasks: [] }) } catch (_) {}
175
+ var leftovers = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return /\.tmp$/.test(f) })
176
+ assert.equal(leftovers.length, 0, 'rename 失败后无 .tmp 残件(残件已被清理)')
177
+ } finally {
178
+ fs.promises.rename = origRename
179
+ env.cleanup()
180
+ }
181
+ })
182
+
183
+ test('④ 锁归一(源码级):四条锁外写路径全部归一进 withLock,dispatch 不再直写 wt', () => {
184
+ var sto = fs.readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
185
+ var disp = fs.readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
186
+ // 锁感知写回 + 不可重入死锁说明必须显式存在
187
+ assert.match(sto, /function lockAware\(sid, locked, fn\)/)
188
+ assert.match(sto, /withLock 是简单 promise 链,不可重入/)
189
+ assert.match(sto, /async function adoptBoard\(sid, cand, cwd, locked\)/)
190
+ assert.match(sto, /async function recoverCorrupt\(sid, raw, locked\)/)
191
+ assert.match(sto, /return lockAware\(sid, locked, async function \(\)/)
192
+ assert.match(sto, /await rt\(sid, true\)/) // mutateLocked 锁内调用 rt 传 true
193
+ // poolCycle 空闲快进清理:不再直写 wt,改走 mutateLocked(锁内重读重判)
194
+ assert.doesNotMatch(disp, /await wt\(/)
195
+ assert.doesNotMatch(disp, /deps\.wt/)
196
+ assert.match(disp, /mutateLocked\(sid, function \(d\) \{/)
197
+ })
198
+
199
+ test('④ poolCycle 空闲清理(行为级):残留忙碌 poolStatus 经锁被清空', async () => {
200
+ var env = createMockCtx()
201
+ plugin.apply(env.ctx)
202
+ try {
203
+ await createTask(env, 'idle1') // 建卡 + touchSession 入 knownSessions
204
+ // 模拟 DSH 重启:内存 runs 已空、文件里残留重启前的忙碌快照 + 无活跃任务 → 走空闲快进清理
205
+ env.patchBoard(function (d) {
206
+ d.tasks = []
207
+ d.poolStatus = { workers: [{ id: 'ghost-w', taskId: 'x', role: 'worker' }], verifiers: [{ id: 'ghost-v', taskId: 'y', role: 'verifier' }] }
208
+ })
209
+ await env.clock.tick() // 触发 15s 心跳 → poolCycle → 空闲清理
210
+ await env.waitFor(function () {
211
+ var b = env.board()
212
+ return b && b.poolStatus.workers.length === 0 && b.poolStatus.verifiers.length === 0
213
+ })
214
+ var b = env.board()
215
+ assert.equal(b.poolStatus.workers.length + b.poolStatus.verifiers.length, 0, '残留忙碌 poolStatus 已被清空')
216
+ } finally { await env.cleanup() }
217
+ })
@@ -0,0 +1,292 @@
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
+ // ===== ② 死结窗口根修(task-mv1pqy27):terminate 认领位清理与状态无关 + 关 run 结局 terminated =====
166
+ test('② 死结窗口:resolved/cancelled 态 terminate(rec 仍在内存表)→ 认领位清 + run 关账,立即 archive 放行', async () => {
167
+ // resolved 态窗口:卡已 resolved,但 verifier run 的 rec 还挂在内存表(~50ms 结算窗口)
168
+ const board = mkBoard([mkTask({ id: 'a6', status: 'resolved', verifierRun: 'run-vX', runs: [{ role: 'verifier', id: 'run-vX', outcome: 'running' }] })])
169
+ const runs = { [FULL_SID]: { 'a6': { id: 'run-vX', role: 'verifier', settled: false } } }
170
+ const closed = []
171
+ const h = mkRpcHarness(board, runs, { closeRunHistory: async (sid, taskId, runId, outcome) => { closed.push({ taskId: taskId, runId: runId, outcome: outcome }); var tt = board.tasks.find(function (x) { return x.id === taskId }); if (tt && Array.isArray(tt.runs)) { var rr = tt.runs[tt.runs.length - 1]; if (rr && rr.id === runId && rr.outcome === 'running') { rr.outcome = outcome; rr.endedAt = '2026-01-01T00:00:01Z' } } } })
172
+ const r = await h['terminate-agent']({ taskId: 'a6' })
173
+ assert.equal(r.ok, true); assert.equal(r.terminated, 'verifier:a6')
174
+ assert.equal(board.tasks[0].verifierRun, null) // 状态无关:resolved 态也清 verifierRun(旧代码只在 verifying 清 → 死结)
175
+ assert.deepEqual(closed, [{ taskId: 'a6', runId: 'run-vX', outcome: 'terminated' }]) // 关 run 结局 terminated
176
+ assert.equal(board.tasks[0].runs[0].outcome, 'terminated')
177
+ const r3 = await h['archive-task']({ taskId: 'a6' })
178
+ assert.equal(r3.ok, true); assert.equal(board.tasks[0].status, 'archived') // 立即 archive 放行(修复前永久拒)
179
+
180
+ // cancelled 态窗口:worker run 的 rec 仍在内存表,claimedBy 仍指已终止 run(closeRunHistory 未注入 = no-op 兜底,只靠认领位清)
181
+ const board2 = mkBoard([mkTask({ id: 'a7', status: 'cancelled', claimedBy: 'run-wX', claimedAt: '2026-01-01T00:00:00Z', runs: [{ role: 'worker', id: 'run-wX', outcome: 'running' }] })])
182
+ const runs2 = { [FULL_SID]: { 'a7': { id: 'run-wX', role: 'worker', settled: false } } }
183
+ const h2 = mkRpcHarness(board2, runs2)
184
+ const rw = await h2['terminate-agent']({ taskId: 'a7' })
185
+ assert.equal(rw.ok, true); assert.equal(rw.terminated, 'worker:a7')
186
+ assert.equal(board2.tasks[0].claimedBy, null); assert.equal(board2.tasks[0].claimedAt, null) // 状态无关:cancelled 态也清 claimedBy
187
+ const rw3 = await h2['archive-task']({ taskId: 'a7' })
188
+ assert.equal(rw3.ok, true); assert.equal(board2.tasks[0].status, 'archived') // 取消卡立即 archive 放行
189
+ })
190
+
191
+ // ===== ③④⑤ 迟到 verdict 落账(工具通道 board_verdict)=====
192
+ test('③ 迟到 approved:board_verdict 落到已 resolved 卡 → 落 lateVerdict,不炸、状态不动', async () => {
193
+ const board = mkBoard([mkTask({ id: 'l1', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
194
+ const h = mkRpcHarness(board, {})
195
+ const r = await h.__tools['board_verdict'].execute({ taskId: 'l1', verdict: 'approved', summary: '复跑通过', checks: '## 核对项\n- ok' }, {})
196
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, false)
197
+ const t = board.tasks[0]
198
+ assert.equal(t.status, 'resolved') // 状态不动
199
+ assert.equal(t.lateVerdict.verdict, 'approved')
200
+ assert.equal(t.lateVerdict.summary, '复跑通过')
201
+ assert.match(t.lateVerdict.note, /迟到结论:卡在落定后收到/)
202
+ // 已落定卡上的 board_verdict 不触发正常回执/重派(返回体带 late 旗即可,无报错)
203
+ })
204
+
205
+ test('④ 迟到 rejected + 卡已 approved:board_verdict → err 通知 + healthHint(lateRejectNote)', async () => {
206
+ const board = mkBoard([mkTask({ id: 'l2', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
207
+ const h = mkRpcHarness(board, {})
208
+ const r = await h.__tools['board_verdict'].execute({ taskId: 'l2', verdict: 'rejected', summary: '单测没过', checks: '## 核对项\n- 红' }, {})
209
+ assert.equal(r.ok, true); assert.equal(r.late, true); assert.equal(r.lateRejected, true)
210
+ const t = board.tasks[0]
211
+ assert.equal(t.lateVerdict.verdict, 'rejected')
212
+ // err 通知已投(notifyLateReject 捕获,直投 owner 不走回执聚合)
213
+ assert.equal(h.__lateRejects.length, 1); assert.equal(h.__lateRejects[0].id, 'l2')
214
+ // healthHint 落点(poolHealth.lateRejectNote,health.mjs computeRuntimeHealthHints 现算透出)
215
+ assert.equal(h.__state.poolHealth[FULL_SID].lateRejectNote.taskId, 'l2')
216
+ })
217
+
218
+ test('⑤ userTest 补挂:迟到结论带 userTest → 补挂 t.verification.userTest;开关关则不挂', async () => {
219
+ const ut = { gist: '改了验收时序', steps: ['打开看板'], expect: '看到提示', tier: 'ui' }
220
+ const board = mkBoard([mkTask({ id: 'l3', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
221
+ const h = mkRpcHarness(board, {})
222
+ await h.__tools['board_verdict'].execute({ taskId: 'l3', verdict: 'rejected', summary: 'x', userTest: ut }, {})
223
+ const t = board.tasks[0]
224
+ assert.ok(t.lateVerdict.userTest, 'lateVerdict 挂 userTest')
225
+ assert.ok(t.verification, '原 verification 缺 → 补建')
226
+ assert.equal(t.verification.userTest.gist, '改了验收时序')
227
+ assert.equal(t.verification.verdict, 'rejected') // 补建的 verification 用迟到结论的 verdict 兜底,非空
228
+ // 开关关 → 不挂 userTest、不补建 verification
229
+ const board2 = mkBoard([mkTask({ id: 'l4', status: 'resolved', verifiedAt: 'x' })]); board2.verifyUserGuide = false
230
+ const h2 = mkRpcHarness(board2, {})
231
+ await h2.__tools['board_verdict'].execute({ taskId: 'l4', verdict: 'rejected', summary: 'x', userTest: ut }, {})
232
+ const t2 = board2.tasks[0]
233
+ assert.equal(t2.lateVerdict.userTest, undefined)
234
+ assert.equal(t2.verification, undefined)
235
+ })
236
+
237
+ // ===== ③④ 迟到 verdict 落账(文本通道 settleVerifier)=====
238
+ test('③④ settleVerifier 迟到:主窗口抢批 resolved 后 Verifier 迟到 REJECTED → 落 lateVerdict + 通知 + healthHint', async () => {
239
+ const t = mkTask({ id: 's1', status: 'verifying' })
240
+ const board = mkBoard([t])
241
+ const h = mkSettleHarness(board)
242
+ await h.dispatch.poolCycle(FULL_SID)
243
+ assert.equal(t.verifierRun, 'run-v1') // 已派 Verifier
244
+ // 主窗口抢批(早于 verifier 结算):直接把卡置 resolved(等价 task_verify force approved,不写 t.verification)
245
+ t.status = 'resolved'; t.verifiedAt = '2026-01-01T00:01:00Z'; t.verifiedBy = 'tester'
246
+ h.settle('REJECTED: 交付物与验收脚本不符\n\n## 测试概要\nnpm test 未跑通\n\n## 核对项\n- 断言①红')
247
+ await waitFor(function () { return t.lateVerdict }, 'settleVerifier 迟到落 lateVerdict')
248
+ assert.equal(t.lateVerdict.verdict, 'rejected')
249
+ assert.match(t.lateVerdict.summary, /npm test 未跑通/)
250
+ assert.match(t.lateVerdict.checks, /断言①红/)
251
+ assert.match(t.lateVerdict.note, /迟到结论/)
252
+ // err 通知 + healthHint
253
+ assert.equal(h.lateRejects.length, 1); assert.equal(h.lateRejects[0], 's1')
254
+ assert.equal(h.state.poolHealth[FULL_SID].lateRejectNote.taskId, 's1')
255
+ // 状态不被迟到的驳回改写(仍 resolved,不再回 pending 重派)
256
+ assert.equal(t.status, 'resolved')
257
+ })
258
+
259
+ test('settleVerifier 迟到落账:工具通道已落账(verification.by === rec.id)→ 不重复落 lateVerdict', async () => {
260
+ const t = mkTask({ id: 's2', status: 'verifying' })
261
+ const board = mkBoard([t])
262
+ const h = mkSettleHarness(board)
263
+ await h.dispatch.poolCycle(FULL_SID)
264
+ assert.equal(t.verifierRun, 'run-v1')
265
+ // 模拟 board_verdict 工具通道已落账(by = 本 run 的 rec.id)并推进到 resolved
266
+ t.verification = { verdict: 'approved', summary: 'OK', checks: '', at: '2026-01-01T00:01:00Z', by: 'run-v1' }
267
+ t.status = 'resolved'; t.verifiedAt = '2026-01-01T00:01:00Z'
268
+ h.settle('APPROVED: ok')
269
+ await new Promise(function (r) { setTimeout(r, 50) }) // 给结算链一点时间跑完,确认不落 lateVerdict
270
+ assert.equal(t.lateVerdict, undefined)
271
+ assert.equal(t.verification.verdict, 'approved') // 原结论不被覆盖
272
+ })
273
+
274
+ // ===== 纯函数口径:lateVerdictApply wasApproved 判定 =====
275
+ test('lateVerdictApply:wasApproved 口径(resolved / archived-from-resolved / archived-from-cancelled)', () => {
276
+ // resolved → approved
277
+ const t1 = mkTask({ id: 'p1', status: 'resolved', verifiedAt: 'x' })
278
+ assert.equal(core.lateVerdictApply(t1, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, true)
279
+ // archived 且带 verifiedAt → approved
280
+ const t2 = mkTask({ id: 'p2', status: 'archived', verifiedAt: 'x' })
281
+ assert.equal(core.lateVerdictApply(t2, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, true)
282
+ // archived 无 approved 痕迹(从 cancelled 归档)→ 非 approved
283
+ const t3 = mkTask({ id: 'p3', status: 'archived' })
284
+ assert.equal(core.lateVerdictApply(t3, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, false)
285
+ // cancelled → 非 approved
286
+ const t4 = mkTask({ id: 'p4', status: 'cancelled' })
287
+ assert.equal(core.lateVerdictApply(t4, 'rejected', 's', 'c', null, 'at', 'by', true).wasApproved, false)
288
+ // approved verdict → lateRejected false
289
+ const t5 = mkTask({ id: 'p5', status: 'resolved', verifiedAt: 'x' })
290
+ const r5 = core.lateVerdictApply(t5, 'approved', 's', 'c', null, 'at', 'by', true)
291
+ assert.equal(r5.lateRejected, false); assert.equal(r5.wasApproved, true)
292
+ })