dsh-agent-board 1.7.5 → 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.
@@ -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
+ })