dsh-agent-board 1.8.0 → 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.
@@ -0,0 +1,282 @@
1
+ // dsh-agent-board — 结算≠停止根修(task-mv8wuznb):一次性 run 落定后主动 interrupt 子会话
2
+ // 事故实证(ontology 2026-10-15):卡 02:02 resolved / verdict 已落账,verifier 子会话仍持续写盘
3
+ // 空转到 10:16 被主窗口手杀——8 小时空烧。settle 只关账不杀会话,三盖齐修:
4
+ // ① settleRunRecord 末尾补停(settleRun 的 completed/failed/incomplete 与 reapGhostRecs 幽灵回收
5
+ // 收尾的唯一汇合点——事故原形「board_verdict 落账 → 卡 resolved → rec 下轮被幽灵回收」即走这里);
6
+ // ② reconcileRoot 重启后扫「落定卡 + running 无 endedAt 留档 + 子会话仍活」的孤儿一次性 run 补停;
7
+ // ③ board_verdict late 分支(卡已落定后收到 verdict)落账后按 actor(= 调用它的 verifier 子会话)补停。
8
+ // 边界(本文件锁定):hook 点位不在本卡范围;continuable 豁免经 task-mvbeallq 收紧——仅「续跑位真在
9
+ // 本 child 手上」(活跃表新 rec 接管同会话 / resumeTarget 仍指向它)才豁免;continuable 但无续跑位的
10
+ // 落定(completed / noResume / 卡面留档被更新的 run 取代)同样补停,堵「续跑豁免」留下的残影续跑窗。
11
+ // 用例 A/B/C/F 跑在「真实 index.mjs apply(mockCtx)」上(与 integration.test.mjs 同一 harness),
12
+ // 用例 E 走 rpc 直调 harness(verify-timing.test.mjs 同款薄桩)。
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
16
+ import * as plugin from '../index.mjs'
17
+ import { createRpc } from '../lib/rpc.mjs'
18
+
19
+ // ===== 测试工具(与 integration.test.mjs 同形)=====
20
+ async function createTask(env, id, extra) {
21
+ var args = { sessionId: env.sid, id: id, title: '补停任务 ' + id, description: '结算≠停止根修用例 ' + id }
22
+ if (extra) { for (var k in extra) args[k] = extra[k] }
23
+ var r = await env.rpc('create-task', args)
24
+ assert.equal(r.ok, true, 'create-task 成功: ' + id)
25
+ return r.task
26
+ }
27
+ function runsOf(t, role) { return (t.runs || []).filter(function (r) { return r.role === role }) }
28
+ function stopsOn(env, childId) { return env.interrupts.filter(function (x) { return x.childId === childId }) }
29
+ // Verifier 结论文本:行首锚定 APPROVED(parseVerdict 口径)
30
+ var APPROVE_TEXT = 'APPROVED\n\n## 验证概要\n验收脚本独立复跑通过\n\n## 核对项\n- npm test 全绿'
31
+
32
+ // ===== ① 一次性 run 正常结算(completed,run.result 通道)→ 子会话收到 interrupt 补停 =====
33
+ test('① verifier 一次性 run 正常结算 → interrupt 补停恰一枪;既有 dispose/关账不回归;continuable worker completed 落定亦补停(task-mvbeallq 收紧)', async () => {
34
+ var env = createMockCtx()
35
+ try {
36
+ plugin.apply(env.ctx)
37
+ await createTask(env, 'task-ss-a', { pipeline: 'full', acceptance: 'npm test' })
38
+ await env.clock.advance(50)
39
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'worker continuable spawn 发生')
40
+ var child = env.continuables[0]
41
+ await env.waitFor(function () { var x = env.task('task-ss-a'); return x && x.status === 'in-progress' ? x : null }, '落卡 in-progress')
42
+ // Worker 上报通道完成 → verifying(continuable rec 经 settleReportedRun 收尾——completed 落定
43
+ // 无续跑位,task-mvbeallq 起同样补停,见下方断言)
44
+ var rep = await env.tools.get('board_report').execute({ taskId: 'task-ss-a', kind: 'complete', summary: '实现', changes: '改了 a', selfTest: 'npm test 全绿', diffStat: '' })
45
+ assert.equal(rep.ok, true)
46
+ await env.clock.advance(50)
47
+ var vrun = await env.waitFor(function () { return env.lastRun('verifier:task-ss-a') }, 'verifier 一次性 spawn 发生')
48
+ // Verifier run.result 落定(正常 completed 路径)→ settleRun → dispose + settleVerifier + 补停
49
+ vrun.resolveWith(APPROVE_TEXT)
50
+ // 直等补停记录(结算链尾序:关账/落卡在先,补停在 settleRunRecord 末尾)
51
+ await env.waitFor(function () { return stopsOn(env, vrun.id).length >= 1 }, 'verifier 结算后 interrupt 补停')
52
+ assert.equal(stopsOn(env, vrun.id).length, 1, '恰好补停一枪(dispose 之外的增量,无重复)')
53
+ assert.equal(stopsOn(env, vrun.id)[0].authority.kind, 'ancestor', 'authority 形状与 endContinuable/doTerminate 同款')
54
+ assert.ok(vrun.disposed, '既有 dispose 收尾不变(补停是增量不是替代)')
55
+ // 结算流程不回归:卡 resolved + verifier run 关账 completed
56
+ await env.waitFor(function () { var x = env.task('task-ss-a'); return x && x.status === 'resolved' && runsOf(x, 'verifier')[0] && runsOf(x, 'verifier')[0].outcome === 'completed' ? x : null }, 'resolved 且 verifier run 关账 completed')
57
+ // continuable worker completed 落定 → 无续跑位 → 补停(task-mvbeallq 豁免收紧直证①):
58
+ // 上报即完成,会话留着只会残影空转(resumeTarget 只认超时/失败结局,completed 永不续跑)。
59
+ await env.waitFor(function () { return stopsOn(env, child.childId).length >= 1 }, 'continuable worker completed 落定补停')
60
+ assert.equal(stopsOn(env, child.childId).length, 1, '恰好一枪(settleReportedRun→settleRunRecord 尾序补停)')
61
+ assert.equal(stopsOn(env, child.childId)[0].authority.kind, 'ancestor', 'authority 形状同款')
62
+ } finally { await env.cleanup() }
63
+ })
64
+
65
+ // ===== ① 一次性 worker(workerContinuable=false 回退开关)失败结算 → interrupt 补停(盖 failed 路径)=====
66
+ test('① 一次性 worker(回退开关关)失败结算 → interrupt 补停;失败重排语义不回归', async () => {
67
+ var env = createMockCtx()
68
+ try {
69
+ plugin.apply(env.ctx)
70
+ var cf = await env.rpc('set-board-config', { sessionId: env.sid, key: 'workerContinuable', value: false })
71
+ assert.equal(cf.ok, true, '回退开关落盘')
72
+ await createTask(env, 'task-ss-b', { pipeline: 'work' })
73
+ await env.clock.advance(50)
74
+ var wrun = await env.waitFor(function () { return env.lastRun('worker:task-ss-b') }, '一次性 worker spawn(开关关 → 不动 continuable 面)')
75
+ assert.equal(env.continuables.length, 0, '回退开关下不走 startContinuable')
76
+ await env.waitFor(function () { var x = env.task('task-ss-b'); return x && x.status === 'in-progress' && x.claimedBy === wrun.id ? x : null }, '落卡 in-progress')
77
+ // 失败落定(timeout/error):补停同样要盖到——失败≠可以留着子会话空转
78
+ wrun.failWith(new Error('boom'))
79
+ await env.waitFor(function () { return stopsOn(env, wrun.id).length >= 1 }, '失败结算也补停(failed 落定路径)')
80
+ var t = await env.waitFor(function () { var x = env.task('task-ss-b'); return x && x.status === 'pending' && x.runs && x.runs[0] && x.runs[0].outcome === 'timeout/error' ? x : null }, '失败回 pending 且关账 timeout/error(不回归)')
81
+ assert.equal(t.retryCount, 1, '失败计数照旧')
82
+ assert.equal(stopsOn(env, wrun.id).length, 1, '恰好一枪')
83
+ } finally { await env.cleanup() }
84
+ })
85
+
86
+ // ===== ① 事故原形:board_verdict 正常分支落 resolved → 活跃 rec 下轮被幽灵回收 → 收尾补停 =====
87
+ // 这是 8h 空烧的真实路径:verdict 经工具通道落账即 resolved,但 run.result 永不落定(turn 未结束),
88
+ // settleRun 跑不到、dispose 永不触发;rec 在下轮 poolCycle 被 reapGhostRecs 按「卡面失活」摘除——
89
+ // 修复前这里只关账不停会话,修复后 settleRunRecord 末尾补一枪 interrupt。
90
+ test('① 事故原形:工具通道 verdict 落 resolved 后,幽灵回收收尾时 interrupt 补停该 verifier', async () => {
91
+ var env = createMockCtx()
92
+ try {
93
+ plugin.apply(env.ctx)
94
+ await createTask(env, 'task-ss-c', { pipeline: 'full', acceptance: 'npm test' })
95
+ await env.clock.advance(50)
96
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'worker spawn')
97
+ await env.waitFor(function () { var x = env.task('task-ss-c'); return x && x.status === 'in-progress' ? x : null }, '落卡 in-progress')
98
+ var rep = await env.tools.get('board_report').execute({ taskId: 'task-ss-c', kind: 'complete', summary: '实现', changes: '改了 c', selfTest: 'ok', diffStat: '' })
99
+ assert.equal(rep.ok, true)
100
+ await env.clock.advance(50)
101
+ var vrun = await env.waitFor(function () { return env.lastRun('verifier:task-ss-c') }, 'verifier spawn')
102
+ // 工具通道正常分支(卡 verifying → resolved)。注意:本例永不 resolveWith——run.result 不落定,
103
+ // 正是事故原形「空转中的 verifier 先交了 verdict」。
104
+ var vd = await env.tools.get('board_verdict').execute({ taskId: 'task-ss-c', verdict: 'approved', summary: '复跑通过', checks: '## 核对项\n- ok' })
105
+ assert.equal(vd.ok, true)
106
+ await env.waitFor(function () { var x = env.task('task-ss-c'); return x && x.status === 'resolved' ? x : null }, 'verdict 落 resolved')
107
+ assert.equal(stopsOn(env, vrun.id).length, 0, '正常分支 verdict 落账瞬间不补停(③只管 late 分支;此处证明补停来自幽灵回收收尾)')
108
+ await env.clock.advance(50) // 下一轮 poolCycle:卡 resolved ≠ verifying → rec 失活被 reap → settleRunRecord 收尾
109
+ await env.waitFor(function () { return stopsOn(env, vrun.id).length >= 1 }, '幽灵回收收尾时 interrupt 补停(事故原形路径)')
110
+ await env.waitFor(function () { var x = env.task('task-ss-c'); return x && runsOf(x, 'verifier')[0] && runsOf(x, 'verifier')[0].outcome === 'completed' ? x : null }, 'verifier run 由幽灵回收关账 completed')
111
+ assert.equal(stopsOn(env, vrun.id).length, 1, '恰好一枪')
112
+ } finally { await env.cleanup() }
113
+ })
114
+
115
+ // ===== ② reconcile 孤儿补停(重启场景):落定卡 + running 留档 + 子会话仍活 → interrupt =====
116
+ // 重启把内存 rec 清零后,① 的补停再也打不到这些会话;reconcile 顺手按 listChildren 目录补枪。
117
+ // 豁免边界同卡锁定:continuable worker 留档不打(留存续跑语义);已死会话(不在目录)不打(杀无可杀)。
118
+ test('② reconcile 孤儿补停:活着的孤儿 verifier/一次性 worker 被 interrupt;continuable 与死会话豁免', async () => {
119
+ var env = createMockCtx()
120
+ try {
121
+ plugin.apply(env.ctx)
122
+ await createTask(env, 'task-ss-d1', { pipeline: 'work' }) // 只为建出看板文件(不 advance,不触发派发)
123
+ // 直接改板构造重启后现场:落定卡上挂 running 无 endedAt 的留档(内存 rec 早已随重启清零)
124
+ env.patchBoard(function (d) {
125
+ var t1 = d.tasks.find(function (x) { return x.id === 'task-ss-d1' })
126
+ t1.status = 'resolved'; t1.resolvedAt = new Date().toISOString()
127
+ t1.runs = [
128
+ { role: 'verifier', id: 'child-orphan-v', at: '2026-01-01T00:00:00Z', outcome: 'running' },
129
+ { role: 'worker', id: 'child-orphan-cont', at: '2026-01-01T00:00:00Z', outcome: 'running', continuable: true },
130
+ { role: 'worker', id: 'child-orphan-w', at: '2026-01-01T00:00:00Z', outcome: 'running' },
131
+ ]
132
+ var t2 = JSON.parse(JSON.stringify(t1))
133
+ t2.id = 'task-ss-d2'; t2.title = '补停任务 task-ss-d2'
134
+ t2.runs = [{ role: 'verifier', id: 'child-orphan-dead', at: '2026-01-01T00:00:00Z', outcome: 'running' }]
135
+ d.tasks.push(t2)
136
+ })
137
+ // 宿主目录:v / cont / w 仍活;dead 已死(不在列)
138
+ env.children.push({ id: 'child-orphan-v' }, { id: 'child-orphan-cont' }, { id: 'child-orphan-w' })
139
+ await env.clock.tick() // 手动触发一次心跳 → poolCycle → 首轮 reconcile(本 host 生命周期只做一次)
140
+ await env.waitFor(function () {
141
+ return stopsOn(env, 'child-orphan-v').length >= 1 && stopsOn(env, 'child-orphan-w').length >= 1
142
+ }, '活着的孤儿 verifier 与一次性 worker 均被补停')
143
+ assert.equal(stopsOn(env, 'child-orphan-cont').length, 0, 'continuable worker 留档豁免(留存续跑语义)')
144
+ assert.equal(stopsOn(env, 'child-orphan-dead').length, 0, '已死会话不补枪(杀无可杀,留档归 sweepZombieRuns 关账)')
145
+ assert.equal(env.interrupts[0].authority.kind, 'ancestor', 'authority 形状同款')
146
+ // 等链尾再 cleanup:同轮 sweepZombieRuns 会把这些僵尸留档关账(stale-closed)落盘——不等它
147
+ // 会撞上 cleanup 删目录的在飞写盘(ENOENT 噪音,harness 纪律见 headless-smoke 同名注记)。
148
+ await env.waitFor(function () {
149
+ var x = env.task('task-ss-d1'); var y = env.task('task-ss-d2')
150
+ return x && y && x.runs.every(function (r) { return r.outcome === 'stale-closed' }) && y.runs.every(function (r) { return r.outcome === 'stale-closed' }) ? true : null
151
+ }, '同轮僵尸留档关账落盘')
152
+ } finally { await env.cleanup() }
153
+ })
154
+
155
+ // ===== ③ board_verdict late 分支补停:卡已落定后收到 verdict → 按 actor 补停该 verifier =====
156
+ // 实证:verifier 迟到交付 verdict 落账后又空转 8h——此时它的 rec 早已被幽灵回收,① 打不到,
157
+ // 只有 late 分支能按 actor(能调工具即证明还活着)补停。
158
+ var LATE_SID = 'session-mock-late-00000000-0001'
159
+ function mkLateTask(over) { return Object.assign({ id: 'lt1', 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 || {}) }
160
+ function mkLateBoard(tasks) { return { version: 11, ownerSession: LATE_SID, boardMode: 'auto', verifyUserGuide: true, tasks: tasks || [] } }
161
+ // 薄桩 harness(verify-timing.test.mjs 同款):直捕 tools 表;subagents.interrupt / rootForSession 可剧本化
162
+ function mkLateHarness(board, opts) {
163
+ opts = opts || {}
164
+ var state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {}, poolHealth: {} }
165
+ var tools = {}
166
+ var interrupts = []
167
+ var parent = { id: LATE_SID }
168
+ var ctx = {
169
+ tools: { register: function (t) { tools[t.name] = t } },
170
+ effect: function () {},
171
+ webServer: { register: function () { return function () {} } },
172
+ }
173
+ if (!opts.noSubagents) ctx.subagents = { interrupt: function (id, auth) { interrupts.push({ id: id, auth: auth }) } }
174
+ createRpc(ctx, state, {
175
+ getActorId: function () { return opts.actor || 'verifier-child-99' },
176
+ resolveRoot: function () { return LATE_SID },
177
+ toolSessionId: function () { return LATE_SID }, rpcSessionId: function () { return LATE_SID },
178
+ rootForSession: function () { return opts.noRoot ? null : parent },
179
+ deriveWorkMode: function () { return 'solo' },
180
+ runsFor: function () { return {} },
181
+ rt: async function () { return board }, mutateLocked: function (sid, fn) { return fn(board) },
182
+ maybeNotify: function () {}, notifyTaskDone: function () {},
183
+ spawnOneShot: function () {}, accumulateRunUsage: function () {}, readContextPack: async function () { return null },
184
+ pushSysNote: function () {}, sessionCwd: function () { return '' },
185
+ hasActiveRun: function () { return false }, notifyLateReject: function () {},
186
+ })
187
+ return { tools: tools, interrupts: interrupts }
188
+ }
189
+
190
+ test('③ 迟到 verdict 落账后 interrupt 该 verifier 子会话(actor = 调用者)', async () => {
191
+ var board = mkLateBoard([mkLateTask({ id: 'lt1', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
192
+ var h = mkLateHarness(board)
193
+ var r = await h.tools['board_verdict'].execute({ taskId: 'lt1', verdict: 'approved', summary: '迟到复跑通过', checks: '## 核对项\n- ok' })
194
+ assert.equal(r.ok, true); assert.equal(r.late, true)
195
+ assert.equal(h.interrupts.length, 1, 'late 落账后补停一枪')
196
+ assert.equal(h.interrupts[0].id, 'verifier-child-99', '补停目标 = 调用者(迟到 verifier 子会话)')
197
+ assert.equal(h.interrupts[0].auth.kind, 'ancestor', 'authority 形状同款')
198
+ assert.equal(h.interrupts[0].auth.agent.id, LATE_SID, '以本板 root 授权')
199
+ assert.ok(board.tasks[0].lateVerdict, '迟到落账本体不回归(lateVerdict 照常落卡)')
200
+ assert.equal(board.tasks[0].status, 'resolved', '卡状态不被改写')
201
+ })
202
+
203
+ test('③ 守卫:主窗口手调(actor=root)/ unknown / 无 interrupt 能力 / 无活 root → 不补停也不炸', async () => {
204
+ // 主窗口手工补录迟到结论:actor=root sid,绝不能打断自己
205
+ var b1 = mkLateBoard([mkLateTask({ id: 'g1', status: 'resolved', verifiedAt: 'x' })])
206
+ var h1 = mkLateHarness(b1, { actor: LATE_SID })
207
+ var r1 = await h1.tools['board_verdict'].execute({ taskId: 'g1', verdict: 'approved', summary: 's' })
208
+ assert.equal(r1.ok, true); assert.equal(r1.late, true)
209
+ assert.equal(h1.interrupts.length, 0, 'actor=root 不补停(防误伤主窗口)')
210
+ // 无调用栈(actor=unknown):目标不可知,跳过
211
+ var b2 = mkLateBoard([mkLateTask({ id: 'g2', status: 'resolved', verifiedAt: 'x' })])
212
+ var h2 = mkLateHarness(b2, { actor: 'unknown' })
213
+ var r2 = await h2.tools['board_verdict'].execute({ taskId: 'g2', verdict: 'approved', summary: 's' })
214
+ assert.equal(r2.ok, true); assert.equal(r2.late, true)
215
+ assert.equal(h2.interrupts.length, 0, 'actor=unknown 不补停')
216
+ // 宿主无 interrupt 能力(老宿主):静默跳过,late 落账不受影响
217
+ var b3 = mkLateBoard([mkLateTask({ id: 'g3', status: 'resolved', verifiedAt: 'x' })])
218
+ var h3 = mkLateHarness(b3, { noSubagents: true })
219
+ var r3 = await h3.tools['board_verdict'].execute({ taskId: 'g3', verdict: 'rejected', summary: 's', checks: 'c' })
220
+ assert.equal(r3.ok, true); assert.equal(r3.late, true); assert.equal(r3.lateRejected, true)
221
+ // 无活 root 可授权(root 已死/重建窗口):跳过
222
+ var b4 = mkLateBoard([mkLateTask({ id: 'g4', status: 'resolved', verifiedAt: 'x' })])
223
+ var h4 = mkLateHarness(b4, { noRoot: true })
224
+ var r4 = await h4.tools['board_verdict'].execute({ taskId: 'g4', verdict: 'approved', summary: 's' })
225
+ assert.equal(r4.ok, true); assert.equal(r4.late, true)
226
+ assert.equal(h4.interrupts.length, 0, '无活 root 不补停')
227
+ })
228
+
229
+ // ===== 边界:continuable worker 失败结算零新增 interrupt(豁免直证,task-mvbeallq 收紧后不回归)=====
230
+ // 失败落定(incomplete)→ resumeTarget 指向本 child = 续跑位在手 → 豁免成立:结算收尾只有
231
+ // endContinuable 那一枪(打断 turn、留存会话);settleRunRecord 末尾的补停对它必须原样放行——
232
+ // 否则续跑语义(sendMessage 冷复活)被杀掉。
233
+ test('边界:continuable worker 失败结算仅 endContinuable 一枪(settleRunRecord 补停豁免)', async () => {
234
+ var env = createMockCtx()
235
+ try {
236
+ plugin.apply(env.ctx)
237
+ await createTask(env, 'task-ss-f', { pipeline: 'work' })
238
+ await env.clock.advance(50)
239
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'continuable spawn 发生')
240
+ var child = env.continuables[0]
241
+ await env.waitFor(function () { var x = env.task('task-ss-f'); return x && x.status === 'in-progress' ? x : null }, '落卡 in-progress')
242
+ // 事件通道失败结算(空文本):settleContinuable → settleRun → endContinuable(一枪)→ settleRunRecord(豁免)
243
+ env.emitStatus(child.childId, 'running')
244
+ env.emitStatus(child.childId, 'idle')
245
+ await env.waitFor(function () { var x = env.task('task-ss-f'); return x && x.status === 'pending' && x.runs && x.runs[0] && x.runs[0].outcome === 'incomplete' ? x : null }, '失败重排 pending 且关账 incomplete')
246
+ await env.waitFor(function () { return stopsOn(env, child.childId).length >= 1 }, 'endContinuable 的留存 interrupt 到达')
247
+ await new Promise(function (r) { setTimeout(r, 50) }) // 给结算链尾留收敛窗口(补停若有漏网必在此时露头)
248
+ assert.equal(stopsOn(env, child.childId).length, 1, '仍只有 endContinuable 一枪:settleRunRecord 的补停对 continuable 原样放行')
249
+ } finally { await env.cleanup() }
250
+ })
251
+
252
+ // ===== ① 收紧(task-mvbeallq):continuable 旧 run 被重派取代(残影形状)→ 幽灵回收收尾补停旧子会话 =====
253
+ // ontology 残影事故形状:池故障后旧 run 已死,主窗口重派的新 Worker 占了认领位与最新留档;旧
254
+ // continuable 子会话若借「continuable 即跳」豁免续跑 = 残影与新 Worker 同批文件互踩。收紧后:
255
+ // resumeTarget 看最新留档(新 run 仍 running)→ 旧 child 无续跑位 → 幽灵回收收尾补停;
256
+ // 新 Worker 的留档/认领位/卡面状态一律不动。
257
+ test('① 收紧:continuable 旧 run 被重派取代(残影形状)→ 幽灵回收收尾补停旧子会话', async () => {
258
+ var env = createMockCtx()
259
+ try {
260
+ plugin.apply(env.ctx)
261
+ await createTask(env, 'task-ss-g', { pipeline: 'work' })
262
+ await env.clock.advance(50)
263
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'continuable spawn 发生')
264
+ var child = env.continuables[0]
265
+ await env.waitFor(function () { var x = env.task('task-ss-g'); return x && x.status === 'in-progress' && x.claimedBy === child.childId ? x : null }, '落卡 in-progress(旧 Worker 认领)')
266
+ // 模拟主窗口重派:认领位易主 + 卡面追加一条更新的 worker 留档(新 run 仍 running)
267
+ env.patchBoard(function (d) {
268
+ var t = d.tasks.find(function (x) { return x.id === 'task-ss-g' })
269
+ t.claimedBy = 'child-new-worker'
270
+ t.runs.push({ role: 'worker', id: 'child-new-worker', at: new Date().toISOString(), outcome: 'running' })
271
+ })
272
+ await env.clock.advance(50) // 下一轮 poolCycle:旧 rec 认领位不符 → 幽灵回收 → settleRunRecord 收尾补停
273
+ await env.waitFor(function () { return stopsOn(env, child.childId).length >= 1 }, '旧子会话残影被补停')
274
+ assert.equal(stopsOn(env, child.childId).length, 1, '恰好一枪(回收路径无 endContinuable,唯补停)')
275
+ assert.equal(stopsOn(env, child.childId)[0].authority.kind, 'ancestor', 'authority 形状同款')
276
+ assert.equal(stopsOn(env, 'child-new-worker').length, 0, '补停目标只打旧残影,不误伤新 Worker')
277
+ var t = await env.waitFor(function () { var x = env.task('task-ss-g'); return x && x.runs && x.runs[0] && x.runs[0].outcome === 'incomplete' ? x : null }, '旧留档关账 incomplete')
278
+ assert.equal(t.status, 'in-progress', '卡面状态不被收尾改动(新 Worker 照常干活)')
279
+ assert.equal(t.claimedBy, 'child-new-worker', '新 Worker 认领位不动')
280
+ assert.equal(t.runs[t.runs.length - 1].outcome, 'running', '新 run 留档不被误关')
281
+ } finally { await env.cleanup() }
282
+ })
@@ -26,11 +26,11 @@ function waitFor(fn, label, ms) {
26
26
  }
27
27
 
28
28
  // ===== RPC/工具直调 harness(board_verdict / task_verify / archive-task / batch-op 三洞行为)=====
29
- function mkRpcHarness(board, runsTable, extra) {
29
+ function mkRpcHarness(board, runsTable, extra, ctxExtra) {
30
30
  const state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {}, poolHealth: {} }
31
31
  const tools = {}
32
32
  const lateRejects = []
33
- const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
33
+ const ctx = Object.assign({ tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }, ctxExtra || {})
34
34
  const deps = Object.assign({
35
35
  getActorId: () => 'tester', resolveRoot: (x) => x,
36
36
  toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
@@ -271,6 +271,131 @@ test('settleVerifier 迟到落账:工具通道已落账(verification.by ===
271
271
  assert.equal(t.verification.verdict, 'approved') // 原结论不被覆盖
272
272
  })
273
273
 
274
+ // ===== terminate 假杀根修(task-mv8jfu1v,事故实证 n-mv8cfndmvfcv)=====
275
+ // 三洞:① 无 rec 也杀——in-progress + claimedBy(continuable 末条留档 running 且 id 一致)照样
276
+ // interrupt 补杀 + 关账卡面 running 留档;② interrupt 抛错/核验未过 → err 级 pushSysNote +
277
+ // healthHint(poolHealth.terminateFailNote);③ hold:true → 同一次落账连带 assignMode=manual。
278
+ function mkTermBoard(taskId, childId) {
279
+ return mkBoard([mkTask({ id: taskId, status: 'in-progress', claimedBy: childId, claimedAt: '2026-01-01T00:00:00Z', runs: [{ role: 'worker', id: childId, at: '2026-01-01T00:00:00Z', outcome: 'running', continuable: true }] })])
280
+ }
281
+ function mkCloseRunHistory(board, closed) {
282
+ // 与生产 dispatch.closeRunHistory 同口径:只关 outcome==='running' 的条目
283
+ return 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)) { for (var i = tt.runs.length - 1; i >= 0; i--) { var rr = tt.runs[i]; if (rr && rr.id === runId && rr.outcome === 'running') { rr.outcome = outcome; rr.endedAt = '2026-01-01T00:00:01Z'; break } } } }
284
+ }
285
+
286
+ test('假杀根修①:无 rec 但 in-progress+claimedBy(continuable 留档 running)→ 照样 interrupt 补杀 + 关账 terminated + 核验过零告警', async () => {
287
+ const parent = { id: FULL_SID }
288
+ const interrupts = [], closed = [], sysNotes = []
289
+ const board = mkTermBoard('zk1', 'child-zombie')
290
+ const h = mkRpcHarness(board, {}, { // 内存表空:rec 已不在(结算竞态/清扫器先摘)——事故原形
291
+ rootForSession: () => parent,
292
+ closeRunHistory: mkCloseRunHistory(board, closed),
293
+ pushSysNote: (sid, text) => sysNotes.push(text),
294
+ }, { subagents: { interrupt: (id, auth) => interrupts.push({ id: id, auth: auth }), listChildren: async () => [] } })
295
+ const r = await h['terminate-agent']({ taskId: 'zk1' })
296
+ assert.equal(r.ok, true); assert.equal(r.terminated, 'worker:zk1') // 补杀命中,不再 no-active-run 空转
297
+ assert.equal('killUnconfirmed' in r, false)
298
+ assert.deepEqual(interrupts, [{ id: 'child-zombie', auth: { kind: 'ancestor', agent: parent } }]) // 按 claimedBy 补杀
299
+ assert.deepEqual(closed, [{ taskId: 'zk1', runId: 'child-zombie', outcome: 'terminated' }]) // 卡面 running 留档被关账
300
+ assert.equal(board.tasks[0].runs[0].outcome, 'terminated')
301
+ assert.equal(board.tasks[0].status, 'pending'); assert.equal(board.tasks[0].claimedBy, null)
302
+ assert.deepEqual(sysNotes, []) // listChildren 核验通过(子会话已不在目录)→ 零告警
303
+ assert.equal(h.__state.poolHealth[FULL_SID], undefined)
304
+ })
305
+
306
+ test('假杀根修①边界:状态可疑宁可不动——一次性留档(无 continuable)/ 认领位与留档不一致 / spawn-pending 占位,均不补杀', async () => {
307
+ const interrupts = []
308
+ const ctxX = { subagents: { interrupt: (id) => interrupts.push(id), listChildren: async () => [] } }
309
+ // 一次性 run(无 continuable 标记):run 随宿主进程生死,无持久会话可补杀
310
+ const b1 = mkBoard([mkTask({ id: 'os1', status: 'in-progress', claimedBy: 'run-1', runs: [{ role: 'worker', id: 'run-1', outcome: 'running' }] })])
311
+ const h1 = mkRpcHarness(b1, {}, { rootForSession: () => ({ id: FULL_SID }) }, ctxX)
312
+ const r1 = await h1['terminate-agent']({ taskId: 'os1' })
313
+ assert.equal(r1.terminated, 'no-active-run'); assert.deepEqual(interrupts, [])
314
+ assert.equal(b1.tasks[0].status, 'pending'); assert.equal(b1.tasks[0].claimedBy, null) // 翻卡兜底不变
315
+ // 认领位与末条留档 id 不一致(状态可疑)→ 不动
316
+ const b2 = mkBoard([mkTask({ id: 'mm1', status: 'in-progress', claimedBy: 'child-B', runs: [{ role: 'worker', id: 'child-A', outcome: 'running', continuable: true }] })])
317
+ const h2 = mkRpcHarness(b2, {}, { rootForSession: () => ({ id: FULL_SID }) }, ctxX)
318
+ const r2 = await h2['terminate-agent']({ taskId: 'mm1' })
319
+ assert.equal(r2.terminated, 'no-active-run'); assert.deepEqual(interrupts, [])
320
+ // spawn-pending 在途占位:无会话可杀(在途 spawn 归落闸重验弃权路径管,集成场景 k/m 锁定)
321
+ const b3 = mkBoard([mkTask({ id: 'sp1', status: 'in-progress', claimedBy: 'spawn-pending' })])
322
+ const h3 = mkRpcHarness(b3, {}, { rootForSession: () => ({ id: FULL_SID }) }, ctxX)
323
+ const r3 = await h3['terminate-agent']({ taskId: 'sp1' })
324
+ assert.equal(r3.terminated, 'no-active-run'); assert.deepEqual(interrupts, [])
325
+ })
326
+
327
+ test('假杀根修②a:有 rec 的 continuable terminate,interrupt 抛错 → err 通知 + healthHint(不再静默跳过)', async () => {
328
+ const sysNotes = []
329
+ const board = mkTermBoard('kf1', 'child-live')
330
+ const runs = { [FULL_SID]: { 'kf1': { id: 'child-live', childId: 'child-live', continuable: true, ran: true, run: null, role: 'worker', taskId: 'kf1', startedAt: Date.now(), model: '', settled: false } } }
331
+ const h = mkRpcHarness(board, runs, {
332
+ rootForSession: () => ({ id: FULL_SID }),
333
+ pushSysNote: (sid, text) => sysNotes.push(text),
334
+ }, { subagents: { interrupt: () => { throw new Error('UNAUTHORIZED: not a live descendant') } } })
335
+ const r = await h['terminate-agent']({ taskId: 'kf1' })
336
+ assert.equal(r.ok, true); assert.equal(r.terminated, 'worker:kf1')
337
+ assert.equal(r.killUnconfirmed, 'child-live')
338
+ assert.equal(sysNotes.length, 1)
339
+ assert.match(sysNotes[0], /终止未生效/); assert.match(sysNotes[0], /interrupt 抛错/)
340
+ assert.match(sysNotes[0], /interrupt_agent(agent_id: child-live)补刀/)
341
+ assert.deepEqual(h.__state.poolHealth[FULL_SID].terminateFailNote.taskId, 'kf1')
342
+ assert.equal(h.__state.poolHealth[FULL_SID].terminateFailNote.childId, 'child-live')
343
+ })
344
+
345
+ test('假杀根修②b:无 rec 补杀后核验未过(子会话仍在 listChildren 目录)→ err 通知 + healthHint', async () => {
346
+ const sysNotes = [], interrupts = []
347
+ const board = mkTermBoard('kf2', 'child-z2')
348
+ const h = mkRpcHarness(board, {}, {
349
+ rootForSession: () => ({ id: FULL_SID }),
350
+ pushSysNote: (sid, text) => sysNotes.push(text),
351
+ }, { subagents: { interrupt: (id) => interrupts.push(id), listChildren: async () => [{ id: 'child-z2', mode: 'continuable' }] } })
352
+ const r = await h['terminate-agent']({ taskId: 'kf2' })
353
+ assert.equal(r.ok, true); assert.equal(r.terminated, 'worker:kf2')
354
+ assert.deepEqual(interrupts, ['child-z2']) // 补杀信号已发
355
+ assert.equal(r.killUnconfirmed, 'child-z2')
356
+ assert.equal(sysNotes.length, 1); assert.match(sysNotes[0], /核验未过/)
357
+ assert.equal(h.__state.poolHealth[FULL_SID].terminateFailNote.taskId, 'kf2')
358
+ })
359
+
360
+ test('假杀根修②c:宿主无 interrupt/listChildren 能力 → 补杀按未生效可见化(老宿主不再静默)', async () => {
361
+ const sysNotes = []
362
+ const board = mkTermBoard('kf3', 'child-z3')
363
+ const h = mkRpcHarness(board, {}, { rootForSession: () => ({ id: FULL_SID }), pushSysNote: (sid, text) => sysNotes.push(text) }, { subagents: {} })
364
+ const r = await h['terminate-agent']({ taskId: 'kf3' })
365
+ assert.equal(r.ok, true)
366
+ assert.equal(r.killUnconfirmed, 'child-z3')
367
+ assert.equal(sysNotes.length, 1); assert.match(sysNotes[0], /无 interrupt 能力或无活 root/)
368
+ assert.equal(h.__state.poolHealth[FULL_SID].terminateFailNote.taskId, 'kf3')
369
+ })
370
+
371
+ test('假杀根修③:hold=true → 同一次落账 pending+assignMode=manual 原子完成(pickDispatch 不再选中本卡)', async () => {
372
+ // 工具通道(task_terminate hold:true)
373
+ const board = mkBoard([mkTask({ id: 'hd1', status: 'in-progress', claimedBy: 'run-w', runs: [{ role: 'worker', id: 'run-w', outcome: 'running' }] })])
374
+ const runs = { [FULL_SID]: { 'hd1': { id: 'run-w', run: { dispose: async () => {} }, role: 'worker', taskId: 'hd1', startedAt: Date.now(), model: '', settled: false } } }
375
+ const h = mkRpcHarness(board, runs)
376
+ const r = await h.__tools['task_terminate'].execute({ taskId: 'hd1', hold: true }, {})
377
+ assert.equal(r.ok, true)
378
+ const t = board.tasks[0]
379
+ assert.equal(t.status, 'pending'); assert.equal(t.claimedBy, null)
380
+ assert.equal(t.assignMode, 'manual') // 同一 mutateLocked 连带转手动——终止+停派原子
381
+ assert.ok(t.history.some(function (x) { return /hold:连带转手动派发/.test(x.note || '') }))
382
+ assert.equal(core.pickDispatch({ tasks: [t] }, 1, 0, null).pendings.length, 0, 'manual 闸门:下轮 poolCycle 不派')
383
+ // RPC 通道(terminate-agent hold:true)同口径
384
+ const boardR = mkBoard([mkTask({ id: 'hd3', status: 'in-progress', claimedBy: 'run-w3', runs: [{ role: 'worker', id: 'run-w3', outcome: 'running' }] })])
385
+ const runsR = { [FULL_SID]: { 'hd3': { id: 'run-w3', run: { dispose: async () => {} }, role: 'worker', taskId: 'hd3', startedAt: Date.now(), model: '', settled: false } } }
386
+ const hR = mkRpcHarness(boardR, runsR)
387
+ await hR['terminate-agent']({ taskId: 'hd3', hold: true })
388
+ assert.equal(boardR.tasks[0].assignMode, 'manual')
389
+ // 对照:不带 hold 的终止仍回 auto(旧行为逐字不破)→ pickDispatch 照常选中
390
+ const board2 = mkBoard([mkTask({ id: 'hd2', status: 'in-progress', claimedBy: 'run-w2', runs: [{ role: 'worker', id: 'run-w2', outcome: 'running' }] })])
391
+ const runs2 = { [FULL_SID]: { 'hd2': { id: 'run-w2', run: { dispose: async () => {} }, role: 'worker', taskId: 'hd2', startedAt: Date.now(), model: '', settled: false } } }
392
+ const h2 = mkRpcHarness(board2, runs2)
393
+ const r2 = await h2['terminate-agent']({ taskId: 'hd2' })
394
+ assert.equal(r2.ok, true)
395
+ assert.equal(board2.tasks[0].assignMode, 'auto')
396
+ assert.equal(core.pickDispatch({ tasks: [board2.tasks[0]] }, 1, 0, null).pendings.length, 1, '无 hold:回 pending 后照常自动重派')
397
+ })
398
+
274
399
  // ===== 纯函数口径:lateVerdictApply wasApproved 判定 =====
275
400
  test('lateVerdictApply:wasApproved 口径(resolved / archived-from-resolved / archived-from-cancelled)', () => {
276
401
  // resolved → approved