dsh-agent-board 1.7.7 → 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.
@@ -2144,6 +2144,18 @@ test('Token 区②:usageSummary 变化纳入 notify 触发(选范围即渲
2144
2144
  assert.equal(/state\.usageSummary !== prevUsage/.test(cli), false)
2145
2145
  })
2146
2146
 
2147
+ test('setUsage 订阅锁(task-mv10lvud 遗留,task-mv1pqy27 补锁):TopPanel update() 内 setUsage(state.usageSummary) 订阅存在(kernel.js 源 + client.js 产物各一条)', () => {
2148
+ const kernel = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
2149
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2150
+ // 订阅声明:usageSummary 换新对象即触发 Token 区重渲染
2151
+ assert.match(kernel, /var _usg = useState\(state\.usageSummary\), setUsage = _usg\[1\]/)
2152
+ assert.match(built, /var _usg = useState\(state\.usageSummary\), setUsage = _usg\[1\]/)
2153
+ // 锁定「订阅存在于 update() 监听器内」:setUsage(state.usageSummary) 落在 update() 函数体、
2154
+ // 且该 update 被 listeners.push 注册(而非游离在组件渲染路径上)
2155
+ assert.match(kernel, /function update\(\) \{[\s\S]*?setUsage\(state\.usageSummary\)[\s\S]*?\}; listeners\.push\(update\)/)
2156
+ assert.match(built, /function update\(\) \{[\s\S]*?setUsage\(state\.usageSummary\)[\s\S]*?\}; listeners\.push\(update\)/)
2157
+ })
2158
+
2147
2159
  test('Token 区③:Top8 与模型分布主数字取总量(有效进 title),caption 写明口径翻转(源码级断言)', () => {
2148
2160
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2149
2161
  // 口径翻转(task-muxnqunk,用户 2026-10-07 裁决「算总的吧」,推翻 task-muwq9u04 有效主显):
@@ -2743,8 +2755,8 @@ test('README 双份:运行区子会话 id 归属标注 + git 纪律红线各
2743
2755
  // 修法:ArchiveView 直接读 state.tasks.filter(status==='archived'),与仪表盘同源全量 + 归档时间倒序 + 顶部注条数。
2744
2756
  test('ArchiveView: 归档数据源读 state.tasks 全量(对齐仪表盘「已归档」),不依赖 state.archived/fetchArchived(源码级断言)', () => {
2745
2757
  const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
2746
- // 数据源必须与仪表盘同源:state.tasks.filter(status==='archived')(get-tasks 全量含归档)
2747
- assert.match(src, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \}\)/)
2758
+ // 数据源必须与仪表盘同源:state.tasks.filter(status==='archived' || status==='cancelled')(get-tasks 全量含归档;取消通道卡2:cancelled 归入本 tab 可见可恢复)
2759
+ assert.match(src, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \|\| t\.status === 'cancelled' \}\)/)
2748
2760
  // 反向断言:不得再读独立 state.archived.filter(异步填库不触发重渲染 → tab 恒空)
2749
2761
  assert.doesNotMatch(src, /state\.archived\.filter/)
2750
2762
  // 不得再挂「空则 fetchArchived」的 useEffect(独立数据源已移除)
@@ -2752,14 +2764,18 @@ test('ArchiveView: 归档数据源读 state.tasks 全量(对齐仪表盘「已
2752
2764
  // 不得有任何 7 日窗口 cutoff(老归档 >7 天被整批滤掉是原根因候选形态之一)
2753
2765
  assert.doesNotMatch(src, /cutoff/)
2754
2766
  assert.doesNotMatch(src, /7 \* 24 \* 60|7 \* 86400000|604800000/)
2755
- // 顶部注明条数(与仪表盘「已归档」同口径)
2756
- assert.match(src, /'共 ' \+ archived\.length \+ ' 条归档/)
2757
- // 归档时间倒序:archivedAt 优先,回退 resolvedAt/createdAt
2758
- assert.match(src, /function archTs\(t\) \{ return t\.archivedAt \|\| t\.resolvedAt \|\| t\.createdAt \|\| '' \}/)
2767
+ // 顶部注明条数:共 N 条;含已取消时补「(含已取消 X 条)」括号段,取消为 0 时省略(task-mv1pqy27 文案口径修正)
2768
+ assert.match(src, /var cancelledCount = archived\.filter\(function \(t\) \{ return t\.status === 'cancelled' \}\)\.length/)
2769
+ assert.match(src, /'共 ' \+ archived\.length \+ ' 条' \+ \(cancelledCount > 0 \? '(含已取消 ' \+ cancelledCount \+ ' 条)' : ''\)/)
2770
+ // 归档/取消时间倒序:archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt
2771
+ assert.match(src, /function archTs\(t\) \{ return t\.archivedAt \|\| t\.cancelledAt \|\| t\.resolvedAt \|\| t\.createdAt \|\| '' \}/)
2772
+ // 取消卡可见标记:行内「已取消」chip + 时间戳回退 cancelledAt
2773
+ assert.match(src, /t\.status === 'cancelled' \? React\.createElement\('span', \{ title: '已取消(可恢复待办)'/)
2774
+ assert.match(src, /\(t\.archivedAt \|\| t\.cancelledAt\) \? ago\(t\.archivedAt \|\| t\.cancelledAt\)/)
2759
2775
  // 组装产物同步(pretest 已跑 build-client)
2760
2776
  const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2761
- assert.match(built, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \}\)/)
2762
- assert.match(built, /'共 ' \+ archived\.length \+ ' 条归档/)
2777
+ assert.match(built, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \|\| t\.status === 'cancelled' \}\)/)
2778
+ assert.match(built, /'共 ' \+ archived\.length \+ ' 条' \+ \(cancelledCount > 0 \? '(含已取消 ' \+ cancelledCount \+ ' 条)' : ''\)/)
2763
2779
  })
2764
2780
 
2765
2781
  // ===== 史诗 hooks=agent run(task-muuw4ov7):host 生命周期接线(pre 闸门 / post 收口 / 薄框架 prompt / 主窗口限定写入)=====
@@ -3665,22 +3681,28 @@ test('可续跑 Worker⑫(卡2 续跑基线):续跑轮没产出 → 残留
3665
3681
  } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
3666
3682
  })
3667
3683
 
3668
- test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run → interrupt 留存(零 dispose),任务回 pending 且刻意不留续跑资格', async () => {
3684
+ test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run → interrupt 留存(零 dispose),任务回 pending 且关 run 结局 terminated(不留续跑资格)', async () => {
3669
3685
  const parent = { id: FULL_SID }
3670
3686
  const calls = { interrupt: [] }
3687
+ const closed = []
3671
3688
  const t = mkTask({ id: 'mt1', title: '手动终止卡', status: 'in-progress', claimedBy: 'child-live', runs: [{ role: 'worker', id: 'child-live', at: '2026-01-01T00:00:00.000Z', model: '', outcome: 'running', hardMin: 120, continuable: true }] })
3672
3689
  const board = mkBoard([t])
3673
3690
  // 活跃 rec:continuable 形态(无 run,只能 interrupt)
3674
3691
  const runs = { mt1: { id: 'child-live', childId: 'child-live', continuable: true, ran: true, run: null, role: 'worker', taskId: 'mt1', startedAt: Date.now(), model: '', settled: false } }
3675
- const h = mkRpcHandlers(board, { rootForSession: () => parent, runsFor: () => runs }, { subagents: { interrupt: (id, auth) => calls.interrupt.push({ id: id, auth: auth }) } })
3692
+ const h = mkRpcHandlers(board, {
3693
+ rootForSession: () => parent, runsFor: () => runs,
3694
+ // 手动终止 run 关账(task-mv1pqy27):与生产 dispatch.closeRunHistory 同口径,只关 outcome==='running' 的条目
3695
+ 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' } } }
3696
+ }, { subagents: { interrupt: (id, auth) => calls.interrupt.push({ id: id, auth: auth }) } })
3676
3697
  const r = await h['terminate-agent']({ taskId: 'mt1' })
3677
3698
  assert.equal(r.ok, true); assert.equal(r.terminated, 'worker:mt1')
3678
3699
  // 终止 = interrupt 留存(不是 dispose):authority 形状同硬超时臂
3679
3700
  assert.deepEqual(calls.interrupt, [{ id: 'child-live', auth: { kind: 'ancestor', agent: parent } }])
3680
3701
  assert.equal(runs.mt1, undefined) // 活跃表摘除(结算通道随之关闭,不会被 idle 事件误结算)
3681
3702
  assert.equal(t.status, 'pending'); assert.equal(t.claimedBy, null) // 既有语义:重新排队
3682
- // 刻意不留续跑资格:结局停在 running → resumeTarget 不认(重派走 fresh spawn,不唤醒刚被终止的子会话)
3683
- assert.equal(t.runs[t.runs.length - 1].outcome, 'running')
3703
+ // 关 run 结局 terminated(与状态无关,task-mv1pqy27):terminated 不满足 resumeTarget 续跑判据 → 仍不留续跑资格
3704
+ assert.deepEqual(closed, [{ taskId: 'mt1', runId: 'child-live', outcome: 'terminated' }])
3705
+ assert.equal(t.runs[t.runs.length - 1].outcome, 'terminated')
3684
3706
  })
3685
3707
 
3686
3708
  test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
@@ -4096,6 +4118,114 @@ test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放
4096
4118
  assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
4097
4119
  })
4098
4120
 
4121
+ // ===== 取消通道(task_cancel 工具 + cancel-task RPC,反馈 n-mv0zbqk...)=====
4122
+ test('cancel-task RPC: draft/pending/blocked 三态可取消,reason 落历史', async () => {
4123
+ const d1 = mkTask({ id: 'd1', status: 'draft' })
4124
+ const p1 = mkTask({ id: 'p1', status: 'pending' })
4125
+ const b1 = mkTask({ id: 'b1', status: 'blocked', resolution: '卡住了' })
4126
+ const board = mkBoard([d1, p1, b1])
4127
+ const h = mkRpcHandlers(board)
4128
+ assert.equal((await h['cancel-task']({ taskId: 'd1', reason: '误建' })).ok, true)
4129
+ assert.equal((await h['cancel-task']({ taskId: 'p1' })).ok, true)
4130
+ assert.equal((await h['cancel-task']({ taskId: 'b1', reason: '放弃' })).ok, true)
4131
+ assert.equal(d1.status, 'cancelled'); assert.ok(d1.cancelledAt)
4132
+ assert.equal(p1.status, 'cancelled'); assert.ok(p1.cancelledAt)
4133
+ assert.equal(b1.status, 'cancelled'); assert.equal(b1.resolution, null) // 阻塞残留 resolution 清空
4134
+ // reason 落历史(actor + note)
4135
+ assert.ok(d1.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' && /取消: 误建/.test(x.note) }))
4136
+ assert.ok(p1.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' && /取消/.test(x.note) }))
4137
+ assert.ok(b1.history.some(function (x) { return x.to === 'cancelled' && /取消: 放弃/.test(x.note) }))
4138
+ })
4139
+
4140
+ test('cancel-task RPC: in-progress(真实 run)/verifying/resolved 拒(terminate/archive 语义)', async () => {
4141
+ const ip = mkTask({ id: 'ip', status: 'in-progress', claimedBy: 'run-x' })
4142
+ const vf = mkTask({ id: 'vf', status: 'verifying' })
4143
+ const rs = mkTask({ id: 'rs', status: 'resolved' })
4144
+ const board = mkBoard([ip, vf, rs])
4145
+ const h = mkRpcHandlers(board)
4146
+ let r = await h['cancel-task']({ taskId: 'ip' })
4147
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/); assert.equal(ip.status, 'in-progress')
4148
+ r = await h['cancel-task']({ taskId: 'vf' })
4149
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/); assert.equal(vf.status, 'verifying')
4150
+ r = await h['cancel-task']({ taskId: 'rs' })
4151
+ assert.equal(r.ok, false); assert.match(r.error, /归档/); assert.equal(rs.status, 'resolved')
4152
+ })
4153
+
4154
+ test('cancel-task RPC: spawn-pending 边缘回退(清认领位,不留死占位)', async () => {
4155
+ const sp = mkTask({ id: 'sp', status: 'in-progress', claimedBy: 'spawn-pending', claimedAt: '2026-01-01T00:00:00Z' })
4156
+ const board = mkBoard([sp])
4157
+ const h = mkRpcHandlers(board)
4158
+ const r = await h['cancel-task']({ taskId: 'sp', reason: 'spawn 在途作废' })
4159
+ assert.equal(r.ok, true)
4160
+ assert.equal(sp.status, 'cancelled')
4161
+ assert.equal(sp.claimedBy, null); assert.equal(sp.claimedAt, null) // 占位回退(与 spawn 失败同口径)
4162
+ assert.ok(sp.history.some(function (x) { return x.from === 'in-progress' && x.to === 'cancelled' }))
4163
+ })
4164
+
4165
+ test('task_cancel 工具:与 cancel-task RPC 同一门禁口径(放行/拒绝/幂等)', async () => {
4166
+ const t = mkTask({ id: 'x1', status: 'pending' })
4167
+ let h = mkRpcHandlers(mkBoard([t]))
4168
+ let r = await h.__tools['task_cancel'].execute({ taskId: 'x1', reason: '走工具通道' }, {})
4169
+ assert.equal(r.ok, true); assert.equal(t.status, 'cancelled')
4170
+ // 幂等:再次取消已取消卡 → alreadyCancelled
4171
+ r = await h.__tools['task_cancel'].execute({ taskId: 'x1' }, {})
4172
+ assert.equal(r.ok, true); assert.equal(r.alreadyCancelled, true)
4173
+ // 执行中拒绝(工具通道同门禁)
4174
+ const t2 = mkTask({ id: 'x2', status: 'in-progress', claimedBy: 'run-y' })
4175
+ h = mkRpcHandlers(mkBoard([t2]))
4176
+ r = await h.__tools['task_cancel'].execute({ taskId: 'x2' }, {})
4177
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/)
4178
+ })
4179
+
4180
+ test('restore 清 cancelledAt(task-mv1pqy27):已取消卡 resetToPending 恢复待办不背取消时间戳(行为级,RPC + 工具双通道)', async () => {
4181
+ // RPC 通道(归档 tab「↩ 恢复待办」走 update-task resetToPending)
4182
+ const c = mkTask({ id: 'rc1', status: 'cancelled', cancelledAt: '2026-01-01T00:00:00Z' })
4183
+ const h = mkRpcHandlers(mkBoard([c]))
4184
+ const r = await h['update-task']({ taskId: 'rc1', resetToPending: true })
4185
+ assert.equal(r.ok, true); assert.equal(c.status, 'pending')
4186
+ assert.equal('cancelledAt' in c, false) // delete 语义:字段消失,不背取消时间戳
4187
+ // 工具通道(task_update resetToPending)同口径
4188
+ const c2 = mkTask({ id: 'rc2', status: 'cancelled', cancelledAt: '2026-01-01T00:00:00Z' })
4189
+ const h2 = mkRpcHandlers(mkBoard([c2]))
4190
+ const r2 = await h2.__tools['task_update'].execute({ taskId: 'rc2', resetToPending: true }, {})
4191
+ assert.equal(r2.ok, true); assert.equal(c2.status, 'pending')
4192
+ assert.equal('cancelledAt' in c2, false)
4193
+ })
4194
+
4195
+ test('terminate 认领位清理与状态无关 + 关 run 结局 terminated(源码级,task-mv1pqy27 死结窗口根修)', () => {
4196
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4197
+ // 认领位清理按命中的 run 角色(rec.role)而非卡状态分支(resolved/archived/cancelled 态也清)
4198
+ assert.match(rpc, /if \(rec\) \{\s*if \(rec\.role === 'verifier'\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
4199
+ assert.match(rpc, /else \{ t\.claimedBy = null; t\.claimedAt = null \}/)
4200
+ // 关 run 结局 outcome:'terminated'(与状态无关,凡 terminate 命中该卡的 run 就关账)
4201
+ assert.match(rpc, /closeRunHistory\(sid, taskId, String\(rec\.id\), 'terminated'\)/)
4202
+ // stale 兜底仍保留:无 rec 的 verifying 卡仍按状态清 verifierRun(rec 不存在时 rec.role 无从判定)
4203
+ assert.match(rpc, /else if \(t\.status === 'verifying'\) \{ t\.verifierRun = null; delete t\.verifierRunAt; ah\(t, 'verifying'/)
4204
+ // 接线:dispatch 导出 closeRunHistory + index 注入 rpc(未接线则 doTerminate 走 no-op 兜底,关账失效)
4205
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
4206
+ const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
4207
+ assert.match(dsp, /closeRunHistory: closeRunHistory/)
4208
+ assert.match(idx, /closeRunHistory: dispatch\.closeRunHistory/)
4209
+ })
4210
+
4211
+ test('task_cancel GUI 按钮门禁:canCancel 三态 + 详情页按钮接线 + confirm 预警 + 产物重组装', () => {
4212
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4213
+ const tdsrc = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
4214
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
4215
+ // canCancel 谓词与 canDelete 同三态口径(draft/pending/blocked)
4216
+ assert.match(ksrc, /function canCancel\(t\) \{ return !!t && \(t\.status === 'draft' \|\| t\.status === 'pending' \|\| t\.status === 'blocked'\) \}/)
4217
+ // confirm 预警 + cancel-task RPC 调用
4218
+ assert.ok(ksrc.indexOf('取消后不再派发,可在归档恢复。确认取消') >= 0, 'kernel.js 缺 confirm 预警')
4219
+ assert.ok(ksrc.indexOf("rpc('cancel-task'") >= 0, 'kernel.js 缺 cancel-task 调用')
4220
+ // 详情页按钮门禁:canCancel(task) 才渲染取消按钮
4221
+ assert.ok(tdsrc.indexOf('canCancel(task) ? React.createElement') >= 0, 'task-detail.js 缺 canCancel 门禁')
4222
+ assert.ok(tdsrc.indexOf('cancelTask(task.id, task.title, setActionMsg)') >= 0, 'task-detail.js 缺取消按钮接线')
4223
+ // 产物重组装:关键接线标记出现在 lib/client.js
4224
+ for (const m of ['function canCancel(t)', "rpc('cancel-task'", '取消后不再派发,可在归档恢复', 'canCancel(task) ? React.createElement']) {
4225
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m)
4226
+ }
4227
+ })
4228
+
4099
4229
  // ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
4100
4230
  test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
4101
4231
  // RPC 通道:归档末子卡 → 父卡 in-progress → verifying
@@ -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
+ })
@@ -6,9 +6,11 @@
6
6
  import { test } from 'node:test'
7
7
  import assert from 'node:assert/strict'
8
8
  import fs from 'node:fs'
9
+ import os from 'node:os'
9
10
  import path from 'node:path'
10
11
  import { createMockCtx } from './helpers/mock-ctx.mjs'
11
12
  import * as plugin from '../index.mjs'
13
+ import { createStore } from '../lib/store.mjs'
12
14
 
13
15
  function boardFile(env) { return path.join(env.home, '.dsh', 'tasks-' + env.sid + '.json') }
14
16
 
@@ -110,3 +112,106 @@ test('③ 自动 salvage:完整 JSON 尾部夹杂撕裂残片 → 截断抢救
110
112
  assert.ok(msg, 'notify 通知 owner(抢救型)')
111
113
  } finally { await env.cleanup() }
112
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
+ })
@@ -162,6 +162,32 @@ test('② 回归:无活跃 rec 的 stale verifying 卡(terminate 空转场
162
162
  assert.equal(r3.ok, true); assert.equal(board.tasks[0].status, 'archived')
163
163
  })
164
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
+
165
191
  // ===== ③④⑤ 迟到 verdict 落账(工具通道 board_verdict)=====
166
192
  test('③ 迟到 approved:board_verdict 落到已 resolved 卡 → 落 lateVerdict,不炸、状态不动', async () => {
167
193
  const board = mkBoard([mkTask({ id: 'l1', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])