dsh-agent-board 1.7.7 → 1.7.9

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,96 @@
1
+ // dsh-agent-board — get-tasks 条件请求瘦身(ifHash 一致不返全量)单测(test/conditional-get-tasks.test.mjs)
2
+ // 背景(task-mv492for,巡检实证 n-mv2turd1o3iv):面板打开期间每 3s 一次 get-tasks 全量响应恒定
3
+ // 1.34MB(tasks 数组含全部卡的 description/context/runs/deliverable 全文),一分钟 ≈26MB 传输。
4
+ // tasksHash 此前只做「收到全量后的客户端渲染短路」,载荷本身从没瘦过。修法=条件请求:
5
+ // 客户端 fetchTasks 带 ifHash=state.tasksHash;host 比对一致 → 返回 {unchanged:true}+轻量字段(不含 tasks)。
6
+ // 覆盖(任务断言①~④):
7
+ // ① hash 一致 → 响应 unchanged:true 且不含 tasks(字节数级:轻量 <10KB vs 全量 >100KB,harness 构造大板)
8
+ // ② 不带 ifHash / ifHash 不一致 / includeArchived / 卡已变化 → 全量返回不回归
9
+ // ③ unchanged 时客户端 state.tasks 保留(源码级:kernel ifHash 接线 + unchanged 分支优先于 hash 比对)
10
+ // ④ 产物重组装(lib/client.js 含 ifHash/unchanged 接线标记)
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import { readFileSync } from 'node:fs'
14
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
15
+ import * as plugin from '../index.mjs'
16
+
17
+ // 多字节字符填充(贴近真实中文看板的字节体量;1 字 = 3 字节 UTF-8)
18
+ function big(n) { return '描'.repeat(n) }
19
+ function sizeOf(x) { return Buffer.byteLength(JSON.stringify(x)) }
20
+
21
+ // ===== ①② 字节数级断言:大板全量 >100KB;hash 一致轻量 <10KB 且不含 tasks =====
22
+ test('①② get-tasks 条件请求:ifHash 一致返轻量(<10KB 无 tasks);不一致/缺 ifHash/includeArchived 全量不回归(>100KB)', async () => {
23
+ var env = createMockCtx()
24
+ try {
25
+ plugin.apply(env.ctx)
26
+ // 构造大板:40 张卡 ×(6KB description + 3KB context.notes)≈ 360KB 级全量
27
+ for (var i = 0; i < 40; i++) {
28
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-big-' + i, title: '大板卡' + i, description: big(2000), contextNotes: big(1000), pipeline: 'work' })
29
+ }
30
+ // 全量基线:不带 ifHash(老客户端/首轮形态)
31
+ var full = await env.rpc('get-tasks', { sessionId: env.sid })
32
+ assert.ok(Array.isArray(full.tasks) && full.tasks.length === 40, '全量返回全部 40 卡')
33
+ assert.ok(!full.unchanged, '全量响应不带 unchanged 标记')
34
+ assert.ok(full.tasksHash, '全量响应附 tasksHash')
35
+ var fullBytes = sizeOf(full)
36
+ assert.ok(fullBytes > 100 * 1024, '大板全量响应 >100KB(实测 ' + fullBytes + 'B)——大板构造达标')
37
+ // ① hash 一致 → 轻量响应
38
+ var lite = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: full.tasksHash })
39
+ assert.equal(lite.unchanged, true, 'hash 一致 → unchanged:true')
40
+ assert.equal(lite.tasks, undefined, '轻量响应不含 tasks 数组')
41
+ assert.equal(lite.tasksHash, full.tasksHash, '轻量响应透出同一 hash')
42
+ var liteBytes = sizeOf(lite)
43
+ assert.ok(liteBytes < 10 * 1024, '轻量响应 <10KB(实测 ' + liteBytes + 'B,全量 ' + fullBytes + 'B)')
44
+ // 轻量字段照常:usageSummary/healthHints/childStats/poolStatus/workMode/isRoot/各开关一个不少
45
+ assert.ok(lite.usageSummary, 'usageSummary 照常返回(KB 级聚合)')
46
+ assert.ok(Array.isArray(lite.healthHints), 'healthHints 照常返回')
47
+ assert.ok(lite.childStats && typeof lite.childStats === 'object', 'childStats 照常返回')
48
+ assert.ok(lite.poolStatus && typeof lite.poolStatus === 'object', 'poolStatus 照常返回')
49
+ assert.ok(lite.workMode, 'workMode 照常返回')
50
+ assert.equal(lite.isRoot, true, 'isRoot 照常返回')
51
+ assert.equal(lite.feedbackEnabled, true)
52
+ assert.equal(lite.notifyDispatch, true)
53
+ assert.equal(lite.cleanupRetentionDays, 7)
54
+ // ② 不带 ifHash → 全量(上面基线已证);ifHash 不一致 → 全量不回归
55
+ var stale = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: 'stalehash' })
56
+ assert.ok(Array.isArray(stale.tasks) && stale.tasks.length === 40, 'ifHash 不一致 → 全量返回')
57
+ assert.ok(!stale.unchanged)
58
+ // includeArchived 即使 ifHash 一致也强制全量(归档 tab 数据源 fetchArchived 不带 ifHash,host 侧双保险)
59
+ var arch = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: full.tasksHash, includeArchived: true })
60
+ assert.ok(Array.isArray(arch.tasks) && arch.tasks.length === 40, 'includeArchived 强制全量')
61
+ assert.ok(!arch.unchanged)
62
+ // 卡变化 → hash 变 → 旧 ifHash 失效返全量(含新卡)
63
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-big-new', title: '新卡', description: 'd', pipeline: 'work' })
64
+ var grown = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: full.tasksHash })
65
+ assert.ok(Array.isArray(grown.tasks) && grown.tasks.length === 41, '卡变化后旧 ifHash 失效 → 全量返回含新卡')
66
+ assert.notEqual(grown.tasksHash, full.tasksHash, 'hash 随卡变化')
67
+ assert.ok(!grown.unchanged)
68
+ } finally { await env.cleanup() }
69
+ })
70
+
71
+ // ===== ③④ 客户端/host 接线(源码级)+ 产物重组装 =====
72
+ test('③④ 接线:fetchTasks 带 ifHash + unchanged 分支保留 state.tasks(源码级)+ client.js 产物重组装', () => {
73
+ var ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
74
+ var rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
75
+ var built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
76
+ // 请求侧:state.tasksHash 非空才带 ifHash(首轮/会话切换后为空 → 全量拉取同老路径)
77
+ assert.match(ksrc, /if \(state\.tasksHash\) rpcArgs\.ifHash = state\.tasksHash/)
78
+ // 响应侧③:unchanged 优先于 hash 比对——缺 tasks 的轻量响应绝不清空 state.tasks
79
+ assert.match(ksrc, /var unchanged = !!\(d && d\.unchanged\)/)
80
+ assert.match(ksrc, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
81
+ // state.tasks 赋值只在 tasksChanged 分支内(unchanged 时保留不动)
82
+ var iBranch = ksrc.indexOf('if (tasksChanged) {')
83
+ var iAssign = ksrc.indexOf('state.tasks = (d && d.tasks) || []')
84
+ assert.ok(iBranch >= 0 && iAssign > iBranch, 'state.tasks 赋值必须在 tasksChanged 分支内(unchanged 保留旧列表)')
85
+ // host 侧:ifHash 一致且非 includeArchived → 剔 tasks + unchanged 标记
86
+ assert.match(rpcSrc, /args\.ifHash && args\.ifHash === d\.tasksHash && !args\.includeArchived/)
87
+ assert.match(rpcSrc, /delete __light\.tasks/)
88
+ assert.match(rpcSrc, /__light\.unchanged = true/)
89
+ // 归档 tab 数据源不带 ifHash(fetchArchived 只传 includeArchived)——双保险的客户端一半
90
+ var fa = ksrc.match(/function fetchArchived\(\)[\s\S]*?\n \}/)
91
+ assert.ok(fa && fa[0].indexOf('ifHash') < 0, 'fetchArchived 不得带 ifHash(归档全量拉取)')
92
+ // ④ 产物重组装:关键接线标记逐一出现在 lib/client.js
93
+ for (const m of ['rpcArgs.ifHash = state.tasksHash', 'd && d.unchanged']) {
94
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
95
+ }
96
+ })
@@ -2101,9 +2101,10 @@ test('Token 区统计范围接线:get-tasks 接 range 透传 + fetchTasks 带
2101
2101
  // host:get-tasks 把 args.range 透传给聚合(过滤在 host 做——run 级数据只在 host)
2102
2102
  assert.match(host, /aggregateUsageSummary\(d\.tasks, args && args\.range\)/)
2103
2103
  assert.match(host, /runs\[i\]\.usage/) // run 级留账(范围过滤的数据源,先于本次改动已在)
2104
- // client:轮询带上当前统计范围;空范围不传 field(老 host 调用形态逐字不变)
2104
+ // client:轮询带上当前统计范围;空范围传 {} 占位(rpc 助手 args||{} 归一,host 只读 range 字段,
2105
+ // 与老 host 调用形态等价;task-mv492for 起 {} 上再挂 ifHash 做条件请求)
2105
2106
  assert.match(cli, /var rgSend = activeRange\(\)/)
2106
- assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : undefined/)
2107
+ assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : \{\}/)
2107
2108
  assert.match(cli, /rpc\('get-tasks', rpcArgs\)/)
2108
2109
  // 范围守卫:响应回来时范围已变则丢弃旧范围的聚合,不覆盖新范围的值
2109
2110
  assert.match(cli, /if \(rgKey === \(rgNow\.from \+ '~' \+ rgNow\.to\)\) state\.usageSummary = \(d && d\.usageSummary\) \|\| null/)
@@ -2144,6 +2145,18 @@ test('Token 区②:usageSummary 变化纳入 notify 触发(选范围即渲
2144
2145
  assert.equal(/state\.usageSummary !== prevUsage/.test(cli), false)
2145
2146
  })
2146
2147
 
2148
+ test('setUsage 订阅锁(task-mv10lvud 遗留,task-mv1pqy27 补锁):TopPanel update() 内 setUsage(state.usageSummary) 订阅存在(kernel.js 源 + client.js 产物各一条)', () => {
2149
+ const kernel = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
2150
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2151
+ // 订阅声明:usageSummary 换新对象即触发 Token 区重渲染
2152
+ assert.match(kernel, /var _usg = useState\(state\.usageSummary\), setUsage = _usg\[1\]/)
2153
+ assert.match(built, /var _usg = useState\(state\.usageSummary\), setUsage = _usg\[1\]/)
2154
+ // 锁定「订阅存在于 update() 监听器内」:setUsage(state.usageSummary) 落在 update() 函数体、
2155
+ // 且该 update 被 listeners.push 注册(而非游离在组件渲染路径上)
2156
+ assert.match(kernel, /function update\(\) \{[\s\S]*?setUsage\(state\.usageSummary\)[\s\S]*?\}; listeners\.push\(update\)/)
2157
+ assert.match(built, /function update\(\) \{[\s\S]*?setUsage\(state\.usageSummary\)[\s\S]*?\}; listeners\.push\(update\)/)
2158
+ })
2159
+
2147
2160
  test('Token 区③:Top8 与模型分布主数字取总量(有效进 title),caption 写明口径翻转(源码级断言)', () => {
2148
2161
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2149
2162
  // 口径翻转(task-muxnqunk,用户 2026-10-07 裁决「算总的吧」,推翻 task-muwq9u04 有效主显):
@@ -2743,8 +2756,8 @@ test('README 双份:运行区子会话 id 归属标注 + git 纪律红线各
2743
2756
  // 修法:ArchiveView 直接读 state.tasks.filter(status==='archived'),与仪表盘同源全量 + 归档时间倒序 + 顶部注条数。
2744
2757
  test('ArchiveView: 归档数据源读 state.tasks 全量(对齐仪表盘「已归档」),不依赖 state.archived/fetchArchived(源码级断言)', () => {
2745
2758
  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' \}\)/)
2759
+ // 数据源必须与仪表盘同源:state.tasks.filter(status==='archived' || status==='cancelled')(get-tasks 全量含归档;取消通道卡2:cancelled 归入本 tab 可见可恢复)
2760
+ assert.match(src, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \|\| t\.status === 'cancelled' \}\)/)
2748
2761
  // 反向断言:不得再读独立 state.archived.filter(异步填库不触发重渲染 → tab 恒空)
2749
2762
  assert.doesNotMatch(src, /state\.archived\.filter/)
2750
2763
  // 不得再挂「空则 fetchArchived」的 useEffect(独立数据源已移除)
@@ -2752,14 +2765,18 @@ test('ArchiveView: 归档数据源读 state.tasks 全量(对齐仪表盘「已
2752
2765
  // 不得有任何 7 日窗口 cutoff(老归档 >7 天被整批滤掉是原根因候选形态之一)
2753
2766
  assert.doesNotMatch(src, /cutoff/)
2754
2767
  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 \|\| '' \}/)
2768
+ // 顶部注明条数:共 N 条;含已取消时补「(含已取消 X 条)」括号段,取消为 0 时省略(task-mv1pqy27 文案口径修正)
2769
+ assert.match(src, /var cancelledCount = archived\.filter\(function \(t\) \{ return t\.status === 'cancelled' \}\)\.length/)
2770
+ assert.match(src, /'共 ' \+ archived\.length \+ ' 条' \+ \(cancelledCount > 0 \? '(含已取消 ' \+ cancelledCount \+ ' 条)' : ''\)/)
2771
+ // 归档/取消时间倒序:archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt
2772
+ assert.match(src, /function archTs\(t\) \{ return t\.archivedAt \|\| t\.cancelledAt \|\| t\.resolvedAt \|\| t\.createdAt \|\| '' \}/)
2773
+ // 取消卡可见标记:行内「已取消」chip + 时间戳回退 cancelledAt
2774
+ assert.match(src, /t\.status === 'cancelled' \? React\.createElement\('span', \{ title: '已取消(可恢复待办)'/)
2775
+ assert.match(src, /\(t\.archivedAt \|\| t\.cancelledAt\) \? ago\(t\.archivedAt \|\| t\.cancelledAt\)/)
2759
2776
  // 组装产物同步(pretest 已跑 build-client)
2760
2777
  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 \+ ' 条归档/)
2778
+ assert.match(built, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \|\| t\.status === 'cancelled' \}\)/)
2779
+ assert.match(built, /'共 ' \+ archived\.length \+ ' 条' \+ \(cancelledCount > 0 \? '(含已取消 ' \+ cancelledCount \+ ' 条)' : ''\)/)
2763
2780
  })
2764
2781
 
2765
2782
  // ===== 史诗 hooks=agent run(task-muuw4ov7):host 生命周期接线(pre 闸门 / post 收口 / 薄框架 prompt / 主窗口限定写入)=====
@@ -3665,22 +3682,28 @@ test('可续跑 Worker⑫(卡2 续跑基线):续跑轮没产出 → 残留
3665
3682
  } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
3666
3683
  })
3667
3684
 
3668
- test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run → interrupt 留存(零 dispose),任务回 pending 且刻意不留续跑资格', async () => {
3685
+ test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run → interrupt 留存(零 dispose),任务回 pending 且关 run 结局 terminated(不留续跑资格)', async () => {
3669
3686
  const parent = { id: FULL_SID }
3670
3687
  const calls = { interrupt: [] }
3688
+ const closed = []
3671
3689
  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
3690
  const board = mkBoard([t])
3673
3691
  // 活跃 rec:continuable 形态(无 run,只能 interrupt)
3674
3692
  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 }) } })
3693
+ const h = mkRpcHandlers(board, {
3694
+ rootForSession: () => parent, runsFor: () => runs,
3695
+ // 手动终止 run 关账(task-mv1pqy27):与生产 dispatch.closeRunHistory 同口径,只关 outcome==='running' 的条目
3696
+ 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' } } }
3697
+ }, { subagents: { interrupt: (id, auth) => calls.interrupt.push({ id: id, auth: auth }) } })
3676
3698
  const r = await h['terminate-agent']({ taskId: 'mt1' })
3677
3699
  assert.equal(r.ok, true); assert.equal(r.terminated, 'worker:mt1')
3678
3700
  // 终止 = interrupt 留存(不是 dispose):authority 形状同硬超时臂
3679
3701
  assert.deepEqual(calls.interrupt, [{ id: 'child-live', auth: { kind: 'ancestor', agent: parent } }])
3680
3702
  assert.equal(runs.mt1, undefined) // 活跃表摘除(结算通道随之关闭,不会被 idle 事件误结算)
3681
3703
  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')
3704
+ // 关 run 结局 terminated(与状态无关,task-mv1pqy27):terminated 不满足 resumeTarget 续跑判据 → 仍不留续跑资格
3705
+ assert.deepEqual(closed, [{ taskId: 'mt1', runId: 'child-live', outcome: 'terminated' }])
3706
+ assert.equal(t.runs[t.runs.length - 1].outcome, 'terminated')
3684
3707
  })
3685
3708
 
3686
3709
  test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
@@ -4096,6 +4119,157 @@ test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放
4096
4119
  assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
4097
4120
  })
4098
4121
 
4122
+ // ===== 取消通道(task_cancel 工具 + cancel-task RPC,反馈 n-mv0zbqk...)=====
4123
+ test('cancel-task RPC: draft/pending/blocked 三态可取消,reason 落历史', async () => {
4124
+ const d1 = mkTask({ id: 'd1', status: 'draft' })
4125
+ const p1 = mkTask({ id: 'p1', status: 'pending' })
4126
+ const b1 = mkTask({ id: 'b1', status: 'blocked', resolution: '卡住了' })
4127
+ const board = mkBoard([d1, p1, b1])
4128
+ const h = mkRpcHandlers(board)
4129
+ assert.equal((await h['cancel-task']({ taskId: 'd1', reason: '误建' })).ok, true)
4130
+ assert.equal((await h['cancel-task']({ taskId: 'p1' })).ok, true)
4131
+ assert.equal((await h['cancel-task']({ taskId: 'b1', reason: '放弃' })).ok, true)
4132
+ assert.equal(d1.status, 'cancelled'); assert.ok(d1.cancelledAt)
4133
+ assert.equal(p1.status, 'cancelled'); assert.ok(p1.cancelledAt)
4134
+ assert.equal(b1.status, 'cancelled'); assert.equal(b1.resolution, null) // 阻塞残留 resolution 清空
4135
+ // reason 落历史(actor + note)
4136
+ assert.ok(d1.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' && /取消: 误建/.test(x.note) }))
4137
+ assert.ok(p1.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' && /取消/.test(x.note) }))
4138
+ assert.ok(b1.history.some(function (x) { return x.to === 'cancelled' && /取消: 放弃/.test(x.note) }))
4139
+ })
4140
+
4141
+ test('cancel-task RPC: in-progress(真实 run)/verifying/resolved 拒(terminate/archive 语义)', async () => {
4142
+ const ip = mkTask({ id: 'ip', status: 'in-progress', claimedBy: 'run-x' })
4143
+ const vf = mkTask({ id: 'vf', status: 'verifying' })
4144
+ const rs = mkTask({ id: 'rs', status: 'resolved' })
4145
+ const board = mkBoard([ip, vf, rs])
4146
+ const h = mkRpcHandlers(board)
4147
+ let r = await h['cancel-task']({ taskId: 'ip' })
4148
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/); assert.equal(ip.status, 'in-progress')
4149
+ r = await h['cancel-task']({ taskId: 'vf' })
4150
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/); assert.equal(vf.status, 'verifying')
4151
+ r = await h['cancel-task']({ taskId: 'rs' })
4152
+ assert.equal(r.ok, false); assert.match(r.error, /归档/); assert.equal(rs.status, 'resolved')
4153
+ })
4154
+
4155
+ test('cancel-task RPC: spawn-pending 边缘回退(清认领位,不留死占位)', async () => {
4156
+ const sp = mkTask({ id: 'sp', status: 'in-progress', claimedBy: 'spawn-pending', claimedAt: '2026-01-01T00:00:00Z' })
4157
+ const board = mkBoard([sp])
4158
+ const h = mkRpcHandlers(board)
4159
+ const r = await h['cancel-task']({ taskId: 'sp', reason: 'spawn 在途作废' })
4160
+ assert.equal(r.ok, true)
4161
+ assert.equal(sp.status, 'cancelled')
4162
+ assert.equal(sp.claimedBy, null); assert.equal(sp.claimedAt, null) // 占位回退(与 spawn 失败同口径)
4163
+ assert.ok(sp.history.some(function (x) { return x.from === 'in-progress' && x.to === 'cancelled' }))
4164
+ })
4165
+
4166
+ test('task_cancel 工具:与 cancel-task RPC 同一门禁口径(放行/拒绝/幂等)', async () => {
4167
+ const t = mkTask({ id: 'x1', status: 'pending' })
4168
+ let h = mkRpcHandlers(mkBoard([t]))
4169
+ let r = await h.__tools['task_cancel'].execute({ taskId: 'x1', reason: '走工具通道' }, {})
4170
+ assert.equal(r.ok, true); assert.equal(t.status, 'cancelled')
4171
+ // 幂等:再次取消已取消卡 → alreadyCancelled
4172
+ r = await h.__tools['task_cancel'].execute({ taskId: 'x1' }, {})
4173
+ assert.equal(r.ok, true); assert.equal(r.alreadyCancelled, true)
4174
+ // 执行中拒绝(工具通道同门禁)
4175
+ const t2 = mkTask({ id: 'x2', status: 'in-progress', claimedBy: 'run-y' })
4176
+ h = mkRpcHandlers(mkBoard([t2]))
4177
+ r = await h.__tools['task_cancel'].execute({ taskId: 'x2' }, {})
4178
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/)
4179
+ })
4180
+
4181
+ test('restore 清 cancelledAt(task-mv1pqy27):已取消卡 resetToPending 恢复待办不背取消时间戳(行为级,RPC + 工具双通道)', async () => {
4182
+ // RPC 通道(归档 tab「↩ 恢复待办」走 update-task resetToPending)
4183
+ const c = mkTask({ id: 'rc1', status: 'cancelled', cancelledAt: '2026-01-01T00:00:00Z' })
4184
+ const h = mkRpcHandlers(mkBoard([c]))
4185
+ const r = await h['update-task']({ taskId: 'rc1', resetToPending: true })
4186
+ assert.equal(r.ok, true); assert.equal(c.status, 'pending')
4187
+ assert.equal('cancelledAt' in c, false) // delete 语义:字段消失,不背取消时间戳
4188
+ // 工具通道(task_update resetToPending)同口径
4189
+ const c2 = mkTask({ id: 'rc2', status: 'cancelled', cancelledAt: '2026-01-01T00:00:00Z' })
4190
+ const h2 = mkRpcHandlers(mkBoard([c2]))
4191
+ const r2 = await h2.__tools['task_update'].execute({ taskId: 'rc2', resetToPending: true }, {})
4192
+ assert.equal(r2.ok, true); assert.equal(c2.status, 'pending')
4193
+ assert.equal('cancelledAt' in c2, false)
4194
+ })
4195
+
4196
+ test('terminate 认领位清理与状态无关 + 关 run 结局 terminated(源码级,task-mv1pqy27 死结窗口根修)', () => {
4197
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4198
+ // 认领位清理按命中的 run 角色(rec.role)而非卡状态分支(resolved/archived/cancelled 态也清)
4199
+ assert.match(rpc, /if \(rec\) \{\s*if \(rec\.role === 'verifier'\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
4200
+ assert.match(rpc, /else \{ t\.claimedBy = null; t\.claimedAt = null \}/)
4201
+ // 关 run 结局 outcome:'terminated'(与状态无关,凡 terminate 命中该卡的 run 就关账)
4202
+ assert.match(rpc, /closeRunHistory\(sid, taskId, String\(rec\.id\), 'terminated'\)/)
4203
+ // stale 兜底仍保留:无 rec 的 verifying 卡仍按状态清 verifierRun(rec 不存在时 rec.role 无从判定)
4204
+ assert.match(rpc, /else if \(t\.status === 'verifying'\) \{ t\.verifierRun = null; delete t\.verifierRunAt; ah\(t, 'verifying'/)
4205
+ // 接线:dispatch 导出 closeRunHistory + index 注入 rpc(未接线则 doTerminate 走 no-op 兜底,关账失效)
4206
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
4207
+ const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
4208
+ assert.match(dsp, /closeRunHistory: closeRunHistory/)
4209
+ assert.match(idx, /closeRunHistory: dispatch\.closeRunHistory/)
4210
+ })
4211
+
4212
+ test('task_cancel GUI 按钮门禁:canCancel 三态 + 详情页按钮接线 + confirm 预警 + 产物重组装', () => {
4213
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4214
+ const tdsrc = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
4215
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
4216
+ // canCancel 谓词与 canDelete 同三态口径(draft/pending/blocked)
4217
+ assert.match(ksrc, /function canCancel\(t\) \{ return !!t && \(t\.status === 'draft' \|\| t\.status === 'pending' \|\| t\.status === 'blocked'\) \}/)
4218
+ // confirm 预警 + cancel-task RPC 调用
4219
+ assert.ok(ksrc.indexOf('取消后不再派发,可在归档恢复。确认取消') >= 0, 'kernel.js 缺 confirm 预警')
4220
+ assert.ok(ksrc.indexOf("rpc('cancel-task'") >= 0, 'kernel.js 缺 cancel-task 调用')
4221
+ // 详情页按钮门禁:canCancel(task) 才渲染取消按钮
4222
+ assert.ok(tdsrc.indexOf('canCancel(task) ? React.createElement') >= 0, 'task-detail.js 缺 canCancel 门禁')
4223
+ assert.ok(tdsrc.indexOf('cancelTask(task.id, task.title, setActionMsg)') >= 0, 'task-detail.js 缺取消按钮接线')
4224
+ // 产物重组装:关键接线标记出现在 lib/client.js
4225
+ for (const m of ['function canCancel(t)', "rpc('cancel-task'", '取消后不再派发,可在归档恢复', 'canCancel(task) ? React.createElement']) {
4226
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m)
4227
+ }
4228
+ })
4229
+
4230
+ // ===== 详情面板「取消」消歧(task-mv493xjs,巡检实证 n-mv2turadjv63 误触 task_cancel)=====
4231
+ test('详情面板消歧:「取消」改名「取消任务」+ 危险操作区视觉分层 + ✕ 关闭钮(源码+产物双锁)', () => {
4232
+ const tdsrc = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
4233
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
4234
+ // ① 新文案源码锁定:按钮标签为「取消任务」,不再出现裸「取消」按钮标签(ic('ban', 10), '取消')
4235
+ assert.ok(tdsrc.indexOf("ic('ban', 10), '取消任务'") >= 0, 'task-detail.js 缺「取消任务」新文案')
4236
+ assert.ok(tdsrc.indexOf("ic('ban', 10), '取消')") < 0, 'task-detail.js 仍残留裸「取消」按钮标签')
4237
+ // ② 视觉分层:破坏性按钮独立成区(虚线分隔 + 「危险操作」前缀 + aria-label),与表单按钮组拉开
4238
+ assert.ok(tdsrc.indexOf("'aria-label': '危险操作'") >= 0, 'task-detail.js 缺危险操作区分组')
4239
+ assert.ok(tdsrc.indexOf("borderTop: '1px dashed ' + C.err") >= 0, 'task-detail.js 缺危险区顶部分隔线')
4240
+ assert.ok(tdsrc.indexOf('危险操作') >= 0, 'task-detail.js 缺「危险操作」前缀标签')
4241
+ // ④ ✕ 关闭钮:详情头部右上显性关闭(与「← 返回看板」同行走 state.detailId = null)
4242
+ assert.ok(tdsrc.indexOf("title: '关闭详情面板(返回看板)'") >= 0, 'task-detail.js 缺 ✕ 关闭钮')
4243
+ // ③ 产物重组装:新文案/分层/关闭钮全部进 lib/client.js
4244
+ for (const m of ["ic('ban', 10), '取消任务'", '危险操作', "title: '关闭详情面板(返回看板)'"]) {
4245
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m)
4246
+ }
4247
+ })
4248
+
4249
+ // ===== GUI RPC 操作者回退(task-mv493xjs,巡检次生观察①:流转轨迹 actor 显示 unknown)=====
4250
+ // 面板 HTTP RPC 无 agent 调用栈 → getActorId() 落 'unknown';rpcActor 回退 args 自带 sessionId。
4251
+ test('GUI RPC actor 回退:getActorId=unknown 时流转历史 actor 落会话身份(不再 unknown)', async () => {
4252
+ const t = mkTask({ id: 'g1', status: 'pending' })
4253
+ const board = mkBoard([t])
4254
+ const h = mkRpcHandlers(board, { getActorId: () => 'unknown' })
4255
+ const r = await h['cancel-task']({ taskId: 'g1', reason: 'GUI 误建' })
4256
+ assert.equal(r.ok, true)
4257
+ // actor 不再是 unknown,而是 rpcSessionId(args) 归一出的会话 id(测试桩=FULL_SID)
4258
+ assert.ok(t.history.some(function (x) { return x.to === 'cancelled' && x.actor === FULL_SID }), 'history actor 未回退到会话 id')
4259
+ assert.ok(!t.history.some(function (x) { return x.actor === 'unknown' }), 'history 仍出现 unknown actor')
4260
+ // 正对照:有真实 initiator 时不回退(保持 actor 原值)
4261
+ const t2 = mkTask({ id: 'g2', status: 'pending' })
4262
+ const h2 = mkRpcHandlers(mkBoard([t2]))
4263
+ await h2['cancel-task']({ taskId: 'g2' })
4264
+ assert.ok(t2.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' }), '有 initiator 时 actor 被意外改写')
4265
+ // 归档通道同口径(GUI 高频操作)
4266
+ const t3 = mkTask({ id: 'g3', status: 'resolved' })
4267
+ const h3 = mkRpcHandlers(mkBoard([t3]), { getActorId: () => 'unknown' })
4268
+ const r3 = await h3['archive-task']({ taskId: 'g3' })
4269
+ assert.equal(r3.ok, true)
4270
+ assert.ok(t3.history.some(function (x) { return x.to === 'archived' && x.actor === FULL_SID }), '归档 history actor 未回退到会话 id')
4271
+ })
4272
+
4099
4273
  // ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
4100
4274
  test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
4101
4275
  // RPC 通道:归档末子卡 → 父卡 in-progress → verifying
@@ -4174,9 +4348,10 @@ test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 t
4174
4348
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4175
4349
  assert.equal((rpcSrc.match(/archiveErr\(sid, t\)/g) || []).length, 3)
4176
4350
  // kernel:hash 短路分支存在(hash 相同跳过 tasks 赋值 + notify;老 host 无 hash 恒视为变化)
4351
+ // task-mv492for:tasksChanged 叠加 unchanged 闸门(host 条件响应不含 tasks 时绝不清空列表)
4177
4352
  const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4178
4353
  assert.match(ksrc, /var newHash = \(d && d\.tasksHash\) \|\| ''/)
4179
- assert.match(ksrc, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
4354
+ assert.match(ksrc, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
4180
4355
  assert.match(ksrc, /if \(tasksChanged\) \{[\s\S]*?notify\(\)/)
4181
4356
  })
4182
4357
 
@@ -4358,13 +4533,37 @@ test('isRoot 持久语义:host 侧改读 parentSession(根=无父),防
4358
4533
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4359
4534
  // isRoot 持久判定入口:根会话=无父,不再读活跃 roots() 集
4360
4535
  assert.match(rpcSrc, /async function isRootPersistent\(sid\)/)
4361
- assert.match(rpcSrc, /snap\.header\) return !snap\.header\.parentSession/)
4536
+ // task-mv59nn9m:判据改用 delegationDepth——无父=根,或 parentSession 有值但 delegationDepth===0(fork/seed 会话)也认根
4537
+ assert.match(rpcSrc, /return !hh\.parentSession \|\| \(typeof hh\.delegationDepth === 'number' && hh\.delegationDepth === 0\)/)
4362
4538
  assert.match(rpcSrc, /d\.isRoot = await isRootPersistent\(sid\)/)
4363
4539
  // 指针注释:防抖仍在客户端 kernel.js applyIsRoot,曾确认 true 需连续 3 次 false 才收
4364
4540
  assert.match(rpcSrc, /防抖仍在客户端:kernel\.js applyIsRoot/)
4365
4541
  assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
4366
4542
  })
4367
4543
 
4544
+ // ===== fork 会话认根(task-mv59nn9m,用户报障:ontology_ai「本体数据平台开发」fork 会话看板不可见)=====
4545
+ // 根因:isRootPersistent stat 主源旧判据 `!header.parentSession` 把 fork/seed 会话
4546
+ //(v3→v4 迁移产物:parentSession 有值 + delegationDepth:0 + isSeeded:true)误判非根 → isRoot:false → 看板入口消失。
4547
+ // 修法:判据改 `!h.parentSession || (typeof h.delegationDepth==='number' && h.delegationDepth===0)`;
4548
+ // delegationDepth 缺省(v3 时代老会话无此字段)且有 parentSession 时维持非根保守判定(兼容 v3 时代子代理)。
4549
+ test('isRootPersistent 判据 delegationDepth(行为级):fork 认根 / 子代理非根 / 老形态保守 / 真根不变', async () => {
4550
+ function mkPersistCtx(header) {
4551
+ return { get: function (name) { if (name === 'sessionPersistence') return { stat: async function () { return { header: header } } }; return undefined } }
4552
+ }
4553
+ // ① fork/seed 头(parentSession 有 + delegationDepth 0)→ 根(本卡修复目标)
4554
+ const h1 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 0 }))
4555
+ assert.equal((await h1['get-tasks']({})).isRoot, true)
4556
+ // ② 真子代理头(parentSession 有 + delegationDepth 1)→ 非根
4557
+ const h2 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 1 }))
4558
+ assert.equal((await h2['get-tasks']({})).isRoot, false)
4559
+ // ③ 老形态头(parentSession 有 + delegationDepth 缺省,v3 时代会话)→ 非根(保守判定不放行)
4560
+ const h3 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent' }))
4561
+ assert.equal((await h3['get-tasks']({})).isRoot, false)
4562
+ // ④ 真根(无 parentSession)→ 根
4563
+ const h4 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({}))
4564
+ assert.equal((await h4['get-tasks']({})).isRoot, true)
4565
+ })
4566
+
4368
4567
  // ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
4369
4568
  // 依赖卡 task-muuw4ov7 落地的真实字段契约(本文件上方 hooks 测试段即其口径):
4370
4569
  // epic.hooks = { pre: { enabled, prompt, state, runId, pending? }, post: 同构 };state ∈ idle|running|done|failed;
@@ -4962,6 +5161,82 @@ test('卡3②③:重启 reconcile——存活的续跑 Worker 被找回重挂
4962
5161
  assert.equal(h.runs['k1'], before)
4963
5162
  })
4964
5163
 
5164
+ // ===== 僵尸 run 留档清扫(task-mv22z61v):落定卡 running 无 endedAt 留档关账 + 清卡面认领位 =====
5165
+ test('僵尸 run 留档清扫:落定卡 running 无 endedAt 留档被关账+清位;在飞卡/活跃 rec 不扫;二次运行零改动', async () => {
5166
+ const nowIso = new Date().toISOString()
5167
+ // ① 僵尸 verifier 留档:resolved 卡 + verifierRun 残留指向死会话 + runs[] 里 verifier:running 无 endedAt
5168
+ const zv = mkTask({ id: 'zv', title: '僵尸 verifier 卡', status: 'resolved', verifierRun: 'v-dead', verifierRunAt: '2026-01-01T00:00:00Z',
5169
+ runs: [{ role: 'verifier', id: 'v-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
5170
+ // ② 僵尸 worker 留档:cancelled 卡 + claimedBy 残留指向死会话
5171
+ const zw = mkTask({ id: 'zw', title: '僵尸 worker 卡', status: 'cancelled', claimedBy: 'w-dead', claimedAt: '2026-01-01T00:00:00Z',
5172
+ runs: [{ role: 'worker', id: 'w-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
5173
+ // ③ 已关账留档:outcome 已落定 + endedAt,非 running 条目不该被碰(不误扫非 running 留档)
5174
+ const zc = mkTask({ id: 'zc', title: '已关账卡', status: 'resolved',
5175
+ runs: [{ role: 'worker', id: 'w-ok', at: '2026-01-01T00:00:00Z', model: '', outcome: 'completed', endedAt: '2026-01-01T00:05:00Z' }] })
5176
+ // ④ 在飞卡(in-progress):running 留档是 reconcile/结算通道辖区,绝不误扫(断言②)
5177
+ const inflight = mkTask({ id: 'fly', title: '在飞卡', status: 'in-progress', claimedBy: 'w-alive', claimedAt: nowIso,
5178
+ runs: [{ role: 'worker', id: 'w-alive', at: nowIso, model: '', outcome: 'running', continuable: true }] })
5179
+ // ⑤ 在飞卡(verifying)+ 活跃 rec:双重护栏——status 非落定 + 内存活跃表里还活着,都该跳过(断言② + 无活跃 rec 才扫)
5180
+ const vfy = mkTask({ id: 'vfy', title: '验收中卡', status: 'verifying', verifierRun: 'v-alive', verifierRunAt: nowIso,
5181
+ runs: [{ role: 'verifier', id: 'v-alive', at: nowIso, model: '', outcome: 'running' }] })
5182
+ const board = mkBoard([zv, zw, zc, inflight, vfy])
5183
+ const h = mkUsageDispatch(board)
5184
+ h.runs['vfy'] = { id: 'v-alive', role: 'verifier', taskId: 'vfy', startedAt: Date.now(), model: '', settled: false }
5185
+ await h.dispatch.poolCycle(FULL_SID)
5186
+ // ① 僵尸 verifier 留档:关账(stale-closed + endedAt + note)+ 清 verifierRun 位 + 留一行 history
5187
+ assert.equal(zv.runs[0].outcome, 'stale-closed')
5188
+ assert.ok(zv.runs[0].endedAt, '僵尸 verifier 留档要带 endedAt')
5189
+ assert.equal(zv.runs[0].note, 'host 重启僵尸留档清扫')
5190
+ assert.equal(zv.verifierRun, null)
5191
+ assert.equal('verifierRunAt' in zv, false)
5192
+ assert.equal(zv.history.filter(function (x) { return /僵尸 run 留档已关账/.test(x.note || '') }).length, 1)
5193
+ // ② 僵尸 worker 留档:关账 + 清 claimedBy 位
5194
+ assert.equal(zw.runs[0].outcome, 'stale-closed')
5195
+ assert.ok(zw.runs[0].endedAt)
5196
+ assert.equal(zw.runs[0].note, 'host 重启僵尸留档清扫')
5197
+ assert.equal(zw.claimedBy, null)
5198
+ assert.equal(zw.claimedAt, null)
5199
+ // ③ 已关账留档零改动
5200
+ assert.equal(zc.runs[0].outcome, 'completed')
5201
+ assert.equal(zc.runs[0].endedAt, '2026-01-01T00:05:00Z')
5202
+ assert.equal(zc.runs[0].note, undefined)
5203
+ // ④⑤ 在飞卡 running 留档零改动(断言②:in-progress/verifying 都不扫)
5204
+ assert.equal(inflight.runs[0].outcome, 'running')
5205
+ assert.equal(inflight.runs[0].endedAt, undefined)
5206
+ assert.equal(inflight.claimedBy, 'w-alive')
5207
+ assert.equal(vfy.runs[0].outcome, 'running')
5208
+ assert.equal(vfy.runs[0].endedAt, undefined)
5209
+ assert.equal(vfy.verifierRun, 'v-alive')
5210
+ // ⑤ 幂等:二次运行零改动(关账后 outcome!=running,不再新增 history 行 / 不再刷新 endedAt)
5211
+ const zvEndedAt = zv.runs[0].endedAt
5212
+ const zwEndedAt = zw.runs[0].endedAt
5213
+ const zvHistoryLen = zv.history.length
5214
+ const zwHistoryLen = zw.history.length
5215
+ await h.dispatch.poolCycle(FULL_SID)
5216
+ assert.equal(zv.runs[0].outcome, 'stale-closed')
5217
+ assert.equal(zv.runs[0].endedAt, zvEndedAt)
5218
+ assert.equal(zv.history.length, zvHistoryLen)
5219
+ assert.equal(zw.runs[0].outcome, 'stale-closed')
5220
+ assert.equal(zw.runs[0].endedAt, zwEndedAt)
5221
+ assert.equal(zw.history.length, zwHistoryLen)
5222
+ })
5223
+
5224
+ test('僵尸 run 留档清扫接线(源码级):sweepZombieRuns 定义 + poolCycle 挂点 + 只清扫落定卡门禁', () => {
5225
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5226
+ // ① 清扫函数定义(含「只对落定卡做」状态门禁与「无活跃 rec 才扫」护栏)
5227
+ assert.match(dsp, /async function sweepZombieRuns\(sid, snap\) \{/)
5228
+ assert.match(dsp, /if \(t\.status !== 'resolved' && t\.status !== 'cancelled' && t\.status !== 'archived'\) return/)
5229
+ assert.match(dsp, /if \(rec && !rec\.settled && String\(rec\.id\) === rid\) return/)
5230
+ assert.match(dsp, /r\.outcome = 'stale-closed'/)
5231
+ assert.match(dsp, /r\.endedAt = new Date\(\)\.toISOString\(\)/)
5232
+ assert.match(dsp, /r\.note = 'host 重启僵尸留档清扫'/)
5233
+ assert.match(dsp, /if \(t\.verifierRun === rid\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
5234
+ assert.match(dsp, /if \(t\.claimedBy === rid\) \{ t\.claimedBy = null; t\.claimedAt = null \}/)
5235
+ assert.match(dsp, /return any \? d : null/) // 无改动不写盘(安静板不因清扫产生心跳写盘)
5236
+ // ② poolCycle 挂点:reconcile 之后、幽灵回收之前(落定卡僵尸留档 reconcile/reap 都管不到,须在空闲快进前兜到)
5237
+ assert.match(dsp, /try \{ await sweepZombieRuns\(sid, snap\) \} catch \(e\) \{ console\.error\('\[task-board\] 僵尸留档清扫失败:/)
5238
+ })
5239
+
4965
5240
  test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次性 + 详情页 ↻ 续跑标注 + 文档收尾', () => {
4966
5241
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
4967
5242
  const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
@@ -5120,8 +5395,9 @@ test('fetchTasks 设置开关权威纠偏:hash 不变时配置变化补 notify
5120
5395
  const iNotify = src.indexOf('if (cfgDelta || usageDelta) notify()')
5121
5396
  const iEsc = src.indexOf('if (tasksChanged) {\n var newEsc')
5122
5397
  assert.ok(iDelta < iNotify && iEsc > iNotify, '补 notify 必须在配置赋值之后、escalation 分支之前')
5123
- // ④ 既有渲染节约(tasksHash 短路)逐字保持——本次只加不删
5124
- assert.match(src, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
5398
+ // ④ 既有渲染节约(tasksHash 短路)结构保持——task-mv492for 起 tasksChanged 叠加 unchanged 闸门
5399
+ // (host 条件响应 unchanged:true 不含 tasks 全量时保留 state.tasks),hash 比对短路语义不变
5400
+ assert.match(src, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
5125
5401
  assert.match(src, /if \(tasksChanged\) \{\s*\n\s*state\.tasksHash = newHash\s*\n\s*state\.tasks = \(d && d\.tasks\) \|\| \[\]\s*\n\s*\}/)
5126
5402
  // ⑤ 产物接线:client.js 为拼装产物,真源改动必须已重建(产物与源一致由 build-client --check 兜底)
5127
5403
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
@@ -10,7 +10,8 @@
10
10
  // · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
11
11
  // · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
12
12
  // · agents.currentInitiator()/roots()/list()/get()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root;env.setRoots 可清空模拟休眠)
13
- // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话)
13
+ // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话;
14
+ // env.persistedSizes[id]=N 可让快照带 sizeBytes——cleanup 体积主源断言用)
14
15
  // · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
15
16
  // · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
16
17
  // · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
@@ -51,6 +52,7 @@ import { Readable } from 'node:stream'
51
52
  * @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
52
53
  * @property {(list:Object[])=>void} setRoots 改写活跃 root 集(传 [] 模拟休眠根会话/重建窗口)
53
54
  * @property {Object} persistedHeaders 持久会话表 sid → { id, parentSession }(写 parentSession 模拟子会话;缺省根会话 parentSession=undefined)
55
+ * @property {Object} persistedSizes 快照体积表 sid → sizeBytes 数字(cleanup 体积主源断言用;缺省=快照无 sizeBytes → 目录遍历回退)
54
56
  * @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
55
57
  * @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
56
58
  * @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
@@ -194,6 +196,7 @@ export function createMockCtx(opts) {
194
196
  var rootsList = [root] // 活跃 root 集(测试可 env.setRoots([]) 清空模拟「休眠根会话」)
195
197
  var persistedHeaders = {} // 持久会话表 sid → SessionHeader(测试可写 parentSession 模拟子会话/根会话)
196
198
  persistedHeaders[sid] = { id: sid, parentSession: undefined }
199
+ var persistedSizes = {} // 快照体积表 sid → sizeBytes(task-mv39ag1e:cleanup 体积主源断言用;缺省=快照无 sizeBytes,走目录遍历回退)
197
200
  var agents = {
198
201
  currentInitiator: function () { return { id: env.actor } },
199
202
  roots: function () { return rootsList },
@@ -237,7 +240,8 @@ export function createMockCtx(opts) {
237
240
  }
238
241
  if (name === 'sessionPersistence') {
239
242
  return {
240
- stat: async function (id) { var h = persistedHeaders[id]; return h ? { header: h } : undefined },
243
+ // 快照形态对齐官方 SessionPersistenceSnapshot:sizeBytes 仅当后端能廉价提供时才带(未登记=缺省字段)
244
+ stat: async function (id) { var h = persistedHeaders[id]; if (!h) return undefined; var snap = { header: h }; if (typeof persistedSizes[id] === 'number') snap.sizeBytes = persistedSizes[id]; return snap },
241
245
  }
242
246
  }
243
247
  return undefined
@@ -274,6 +278,7 @@ export function createMockCtx(opts) {
274
278
  actor: sid,
275
279
  setRoots: function (list) { rootsList = list || [] }, // 清空/改写活跃 root 集(模拟休眠/重建窗口)
276
280
  persistedHeaders: persistedHeaders, // 持久会话表(sid → { id, parentSession });写 parentSession 模拟子会话
281
+ persistedSizes: persistedSizes, // 快照体积表(sid → sizeBytes 数字);cleanup 体积主源断言用
277
282
 
278
283
  // 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
279
284
  rpc: async function (method, args) {
@@ -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
+ })