dsh-agent-board 1.7.1 → 1.7.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.
- package/README.md +17 -9
- package/index.mjs +5 -3
- package/lib/client/board-list.js +37 -5
- package/lib/client/dashboard.js +111 -13
- package/lib/client/kernel.js +43 -10
- package/lib/client/task-detail.js +136 -9
- package/lib/client.js +327 -37
- package/lib/core.mjs +308 -20
- package/lib/dispatch.mjs +154 -12
- package/lib/health.mjs +43 -11
- package/lib/notify.mjs +42 -12
- package/lib/policy.mjs +17 -1
- package/lib/rpc.mjs +49 -11
- package/lib/session.mjs +7 -2
- package/lib/store.mjs +2 -1
- package/lib/usage.mjs +84 -3
- package/package.json +1 -1
- package/test/core.test.mjs +1544 -39
- package/test/health.test.mjs +86 -4
package/test/core.test.mjs
CHANGED
|
@@ -10,12 +10,13 @@ import zlib from 'node:zlib'
|
|
|
10
10
|
import * as core from '../lib/core.mjs'
|
|
11
11
|
// 粒度治理(软闸门)住在 lib/policy.mjs(策略层,纯函数)、usage 聚合住在 lib/usage.mjs,
|
|
12
12
|
// 均由 index.mjs 薄壳 re-export(对外契约不变),这里仍从 index.mjs 导入直接断言
|
|
13
|
-
import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE } from '../index.mjs'
|
|
14
|
-
import { aggregateUsageSummary, readRunUsage, findRunLog } from '../index.mjs'
|
|
13
|
+
import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE, splitRuleOf } from '../index.mjs'
|
|
14
|
+
import { aggregateUsageSummary, readRunUsage, findRunLog, effectiveTokens, taskEffectiveTokens } from '../index.mjs'
|
|
15
15
|
import { createRpc } from '../lib/rpc.mjs'
|
|
16
16
|
// no-root 刷屏根治(task-muuf0o7a)专项:会话层幻影板防线 + 派发层 root 闸门直调断言
|
|
17
17
|
import { createSession, isFullSessionId } from '../lib/session.mjs'
|
|
18
18
|
import { createDispatch } from '../lib/dispatch.mjs'
|
|
19
|
+
import { createNotify } from '../lib/notify.mjs'
|
|
19
20
|
// host 端源码拼接(Phase 2 模块化后:薄壳 index.mjs + lib/*.mjs 领域模块),供源码级接线断言
|
|
20
21
|
const HOST_SOURCES = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/notify.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
|
|
21
22
|
function hostSrc() { return HOST_SOURCES.map(function (f) { return readFileSync(new URL(f, import.meta.url), 'utf8') }).join('\n') }
|
|
@@ -79,6 +80,82 @@ test('子任务全 resolved → 父任务自动 verifying', () => {
|
|
|
79
80
|
assert.equal(r.parentUpdated, true); assert.equal(p.status, 'verifying')
|
|
80
81
|
})
|
|
81
82
|
|
|
83
|
+
// ===== 史诗自动收口补漏(task-muuoduri):work 管线子任务也要收口 =====
|
|
84
|
+
// 实证 bug:resolveApply 先把非 full 管线的 status 由 verifying 改写成 resolved,
|
|
85
|
+
// 旧的父卡检查绑在 `status === 'verifying'` 分支上 → work 档子任务(无 Verifier)永不触发父卡收口,
|
|
86
|
+
// epic 永久卡 in-progress。修法:判定抽成共享 helper maybeAutoCloseParent,resolveApply 落定后无条件调用。
|
|
87
|
+
test('史诗收口①:work 管线子任务 resolve → 父卡自动转 verifying(核心回归)', () => {
|
|
88
|
+
const p = mkTask({ id: 'epic', status: 'in-progress' }) // epic 父卡(pipeline=direct,不下池)
|
|
89
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
|
|
90
|
+
const d = mkBoard([p, c])
|
|
91
|
+
const r = core.resolveApply(d, c, 'w', 'verifying', 'done')
|
|
92
|
+
assert.equal(c.status, 'resolved') // work 档:resolve 即落 resolved(无 Verifier)
|
|
93
|
+
assert.equal(r.parentUpdated, true) // 父卡收口被触发(旧实现此处为 undefined → 永久卡死)
|
|
94
|
+
assert.equal(p.status, 'verifying')
|
|
95
|
+
assert.equal(p.resolution, 'all subtasks resolved')
|
|
96
|
+
assert.equal(p.history[p.history.length - 1].actor, 'system')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('史诗收口②:full 档子任务 verify approved 收口仍然成立(既有防回归)', () => {
|
|
100
|
+
const p = mkTask({ id: 'epic', status: 'in-progress' })
|
|
101
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'verifying', pipeline: 'full' })
|
|
102
|
+
const d = mkBoard([p, c])
|
|
103
|
+
const r = core.verifyApply(d, c, 'v', 'approved', 'ok')
|
|
104
|
+
assert.equal(r.parentUpdated, true)
|
|
105
|
+
assert.equal(p.status, 'verifying')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('史诗收口③:cancelled 子任务计入终态完成集 → 父卡也能收口', () => {
|
|
109
|
+
const p = mkTask({ id: 'epic', status: 'in-progress' })
|
|
110
|
+
const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
111
|
+
const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'cancelled' })
|
|
112
|
+
const c3 = mkTask({ id: 'c3', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
|
|
113
|
+
const d = mkBoard([p, c1, c2, c3])
|
|
114
|
+
assert.equal(core.isChildSettled(c1), true)
|
|
115
|
+
assert.equal(core.isChildSettled(c2), true) // 人主动取消 = 该子任务范围已了结
|
|
116
|
+
assert.equal(core.isChildSettled(c3), false)
|
|
117
|
+
assert.equal(core.isChildSettled(mkTask({ status: 'archived' })), false) // 归档不算(收尾动作,不反向推动)
|
|
118
|
+
const r = core.resolveApply(d, c3, 'w', 'verifying', 'done')
|
|
119
|
+
assert.equal(r.parentUpdated, true)
|
|
120
|
+
assert.equal(p.status, 'verifying')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('史诗收口④:仍有 pending/in-progress 子任务 → 不收口', () => {
|
|
124
|
+
const p = mkTask({ id: 'epic', status: 'in-progress' })
|
|
125
|
+
const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
|
|
126
|
+
const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'pending' })
|
|
127
|
+
const d = mkBoard([p, c1, c2])
|
|
128
|
+
const r = core.resolveApply(d, c1, 'w', 'verifying', 'done')
|
|
129
|
+
assert.equal(c1.status, 'resolved')
|
|
130
|
+
assert.equal('parentUpdated' in r, false)
|
|
131
|
+
assert.equal(p.status, 'in-progress') // 未被误收口
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('史诗收口⑤:父卡已 verifying/resolved → 幂等不重复触发(无副作用)', () => {
|
|
135
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
|
|
136
|
+
const pv = mkTask({ id: 'epic', status: 'verifying' })
|
|
137
|
+
const dv = mkBoard([pv, c])
|
|
138
|
+
assert.equal(core.maybeAutoCloseParent(dv, c), null)
|
|
139
|
+
assert.equal(pv.history.length, 0) // 无 history 追加 = 零副作用
|
|
140
|
+
const pr = mkTask({ id: 'epic', status: 'resolved' })
|
|
141
|
+
const dr = mkBoard([pr, c])
|
|
142
|
+
assert.equal(core.maybeAutoCloseParent(dr, c), null)
|
|
143
|
+
assert.equal(pr.history.length, 0)
|
|
144
|
+
// 无 parentId(顶层任务)与父卡不存在 → 一律 null
|
|
145
|
+
assert.equal(core.maybeAutoCloseParent(mkBoard([mkTask({ id: 'lonely' })]), mkTask({ id: 'lonely' })), null)
|
|
146
|
+
assert.equal(core.maybeAutoCloseParent(mkBoard([c]), c), null) // 父卡不在板上(悬空引用)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('史诗收口⑥:checkParentAuto 兼容名委托共享 helper(单一判定口径)', () => {
|
|
150
|
+
const p = mkTask({ id: 'epic', status: 'in-progress' })
|
|
151
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
152
|
+
const d = mkBoard([p, c])
|
|
153
|
+
const got = core.checkParentAuto(d, c)
|
|
154
|
+
assert.equal(got, p)
|
|
155
|
+
assert.equal(p.status, 'verifying')
|
|
156
|
+
assert.equal(p.resolution, 'all subtasks resolved') // 口径文案统一(旧 resolveApply 分支的 'all subtasks done' 已退役)
|
|
157
|
+
})
|
|
158
|
+
|
|
82
159
|
// ===== 依赖 =====
|
|
83
160
|
test('validateDeps: 自引用/不存在/成环 全拒绝', () => {
|
|
84
161
|
const a = mkTask({ id: 'a' }); const b = mkTask({ id: 'b', dependsOn: ['a'] })
|
|
@@ -501,21 +578,88 @@ test('touchesConflict: 与锁持有者逐条比对,返回冲突持有者 id',
|
|
|
501
578
|
assert.deepEqual(core.touchesConflict({ id: 'h1', touches: ['src/a.js'] }, holds), []) // 不和自己冲突
|
|
502
579
|
})
|
|
503
580
|
|
|
504
|
-
test('holdsFiles:
|
|
581
|
+
test('holdsFiles: in-progress(claimedBy) / verifying / resolved 三态持有文件锁,其余不持有', () => {
|
|
505
582
|
const tasks = [
|
|
506
583
|
mkTask({ id: 'live', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
|
|
507
584
|
mkTask({ id: 'notouch', status: 'in-progress', claimedBy: 'run-2' }),
|
|
508
585
|
mkTask({ id: 'noclaim', status: 'in-progress' }),
|
|
509
586
|
mkTask({ id: 'pending', status: 'pending', touches: ['src/a.js'] }),
|
|
587
|
+
// task-muv7c8ja:verifying/resolved 都持锁(锁随卡的生命周期走,归档才真释放)
|
|
510
588
|
mkTask({ id: 'verifying', status: 'verifying', touches: ['src/b.js'] }),
|
|
589
|
+
// resolved 卡不查 claimedBy(resolveApply 转 resolved 前 worker 已停笔,claimedBy 可能已清/保留)
|
|
590
|
+
mkTask({ id: 'resolved', status: 'resolved', touches: ['src/c.js'] }),
|
|
511
591
|
mkTask({ id: 'empty', status: 'in-progress', claimedBy: 'run-3', touches: [] }),
|
|
512
592
|
]
|
|
513
593
|
const holds = core.holdsFiles(mkBoard(tasks))
|
|
514
|
-
assert.deepEqual(holds.map(h => h.id), ['live'])
|
|
594
|
+
assert.deepEqual(holds.map(h => h.id), ['live', 'verifying', 'resolved'])
|
|
515
595
|
assert.deepEqual(holds[0].touches, ['src/**'])
|
|
516
596
|
assert.deepEqual(core.holdsFiles(null), [])
|
|
517
597
|
})
|
|
518
598
|
|
|
599
|
+
// ===== touches 锁延长至归档(task-muv7c8ja / 反馈 n-muupqg81u575)四类断言 =====
|
|
600
|
+
test('touches 持锁①:resolved 未归档 + 同 touches 候选 → 候选不派发(验收后-提交前窗口期不放锁)', () => {
|
|
601
|
+
const holder = mkTask({ id: 'holder', status: 'resolved', touches: ['src/**'] })
|
|
602
|
+
const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
|
|
603
|
+
const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
|
|
604
|
+
assert.deepEqual(r.pendings, []) // 锁未放:候选一张都不派
|
|
605
|
+
assert.deepEqual(r.blockedTouches, [{ id: 'cand', conflicts: ['holder'] }]) // 滞留原因对 UI 可见(等的是 resolved 卡)
|
|
606
|
+
// 不依赖候选自身状态:resolved 持锁只与 touches 声明有关
|
|
607
|
+
assert.deepEqual(core.holdsFiles(mkBoard([holder])).map(h => h.id), ['holder'])
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
test('touches 持锁②:holder 归档后 → 候选下一轮自动放行(归档是唯一真释放点)', () => {
|
|
611
|
+
const holder = mkTask({ id: 'holder', status: 'resolved', touches: ['src/**'] })
|
|
612
|
+
const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
|
|
613
|
+
assert.deepEqual(core.pickDispatch(mkBoard([holder, cand]), 5, 0, null).pendings, []) // 归档前:拦住
|
|
614
|
+
holder.status = 'archived' // 主窗口提交后的归档动作
|
|
615
|
+
const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
|
|
616
|
+
assert.deepEqual(r.pendings.map(t => t.id), ['cand'])
|
|
617
|
+
assert.deepEqual(r.blockedTouches, [])
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
test('touches 持锁③:cancelled 立即放锁(放弃语义=不再产出,不堵同批文件)', () => {
|
|
621
|
+
const holder = mkTask({ id: 'holder', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] })
|
|
622
|
+
const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
|
|
623
|
+
assert.deepEqual(core.pickDispatch(mkBoard([holder, cand]), 5, 0, null).blockedTouches, [{ id: 'cand', conflicts: ['holder'] }])
|
|
624
|
+
holder.status = 'cancelled'
|
|
625
|
+
assert.deepEqual(core.holdsFiles(mkBoard([holder])), []) // 取消即放锁(不等归档)
|
|
626
|
+
const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
|
|
627
|
+
assert.deepEqual(r.pendings.map(t => t.id), ['cand'])
|
|
628
|
+
assert.deepEqual(r.blockedTouches, [])
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('touches 持锁④:verifying 持锁拦候选,但 Verifier 派发(verifs)照常不受影响', () => {
|
|
632
|
+
const vt = mkTask({ id: 'vt', status: 'verifying', pipeline: 'full', touches: ['src/**'] })
|
|
633
|
+
const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
|
|
634
|
+
const r = core.pickDispatch(mkBoard([vt, cand]), 5, 5, null)
|
|
635
|
+
assert.deepEqual(r.blockedTouches, [{ id: 'cand', conflicts: ['vt'] }]) // verifying 仍持锁
|
|
636
|
+
assert.deepEqual(r.verifs.map(t => t.id), ['vt']) // 只读的 Verifier 不被 touches 拦
|
|
637
|
+
// 驳回会回 in-progress 继续改同一批文件 → 锁在 verifying 期间不能断
|
|
638
|
+
assert.deepEqual(core.holdsFiles(mkBoard([vt])).map(h => h.id), ['vt'])
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
test('touches 锁生命周期接线(源码级):holdsFiles 三态口径 + 不再有 resolved 放锁调用点', () => {
|
|
642
|
+
const src = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
643
|
+
// ① 三态持锁口径写在 holdsFiles 里(verifying 与 resolved 都持,in-progress 仍需 claimedBy)
|
|
644
|
+
assert.match(src, /var holds = t\.status === 'verifying' \|\| t\.status === 'resolved' \|\| \(t\.status === 'in-progress' && !!t\.claimedBy\)/)
|
|
645
|
+
// ② 锁不再随 in-progress→verifying 释放(旧注释口径已退役)
|
|
646
|
+
assert.doesNotMatch(src, /verifying 不持有/)
|
|
647
|
+
// ③ cancelled/archived 明确不在持锁集合里
|
|
648
|
+
assert.doesNotMatch(src, /t\.status === 'cancelled' && !!t\.claimedBy/)
|
|
649
|
+
// ④ 派发层不再有「resolved 时放锁」的动作调用(旧实现 releaseTouchesOnly 已不存在)
|
|
650
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
651
|
+
assert.doesNotMatch(dsp, /releaseTouchesOnly/)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
test('README 双份同步记录 touches 锁持到归档口径(锁生命周期 = 卡生命周期)', () => {
|
|
655
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
656
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
657
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
658
|
+
for (const s of ['锁持到归档', 'verifying', 'resolved', 'cancelled']) {
|
|
659
|
+
assert.ok(pkg.includes(s), 'README 应记录 touches 锁口径:' + s)
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
|
|
519
663
|
// ===== 派发决策(touches 拦截)=====
|
|
520
664
|
test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 blockedTouches', () => {
|
|
521
665
|
const tasks = [
|
|
@@ -529,15 +673,15 @@ test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 b
|
|
|
529
673
|
assert.deepEqual(r.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }])
|
|
530
674
|
})
|
|
531
675
|
|
|
532
|
-
test('pickDispatch: verifying
|
|
676
|
+
test('pickDispatch: verifying 持有文件锁(锁未断),但 verifier 派发(verifs)不受 touches 影响', () => {
|
|
533
677
|
const tasks = [
|
|
534
|
-
// verifying
|
|
678
|
+
// task-muv7c8ja 后:verifying 仍持锁(驳回会回 in-progress 继续改同一批文件,验收后-提交前窗口期也属本卡)
|
|
535
679
|
mkTask({ id: 'vt', status: 'verifying', pipeline: 'full', claimedBy: 'run-1', touches: ['src/**'] }),
|
|
536
680
|
mkTask({ id: 'w1', touches: ['src/a.js'] }),
|
|
537
681
|
]
|
|
538
682
|
const r = core.pickDispatch(mkBoard(tasks), 5, 5, null)
|
|
539
|
-
assert.deepEqual(r.pendings
|
|
540
|
-
assert.deepEqual(r.blockedTouches, [])
|
|
683
|
+
assert.deepEqual(r.pendings, []) // 被 verifying 任务拦住,本轮不派
|
|
684
|
+
assert.deepEqual(r.blockedTouches, [{ id: 'w1', conflicts: ['vt'] }])
|
|
541
685
|
assert.deepEqual(r.verifs.map(t => t.id), ['vt']) // Verifier 照常派(只读,不参与排他)
|
|
542
686
|
// 反向:pending 任务声明 touches 也不影响 verifs 派发
|
|
543
687
|
const tasks2 = [mkTask({ id: 'w2', touches: ['src/**'] }), mkTask({ id: 'v2', status: 'verifying', pipeline: 'full' })]
|
|
@@ -565,20 +709,25 @@ test('pickDispatch: frozen/dependsOn/escalation 优先级不变(touches 拦截
|
|
|
565
709
|
const tasks = [
|
|
566
710
|
mkTask({ id: 'holder', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
|
|
567
711
|
mkTask({ id: 'fz', frozen: true, touches: ['src/a.js'] }), // 冻结 → 既有语义直接排除,不记 blockedTouches
|
|
568
|
-
|
|
712
|
+
// dep 的 touches 走 lib/:只验「依赖未满足被既有语义排除」,不与 clash 抢同一把锁
|
|
713
|
+
// (holder 归档后 dep 的依赖即满足、会被派发并拿下 src/a.js,抢锁会掩盖本条想验的语义)
|
|
714
|
+
mkTask({ id: 'dep', dependsOn: ['holder'], touches: ['lib/a.js'] }),
|
|
569
715
|
mkTask({ id: 'esc', escalation: { question: 'q' }, touches: ['src/a.js'] }),// 待裁决 → 排除
|
|
570
716
|
mkTask({ id: 'clash', touches: ['src/a.js'] }), // 唯一被 touches 拦下的
|
|
571
717
|
]
|
|
572
718
|
const r = core.pickDispatch(mkBoard(tasks), 5, 0, null)
|
|
573
719
|
assert.deepEqual(r.pendings, [])
|
|
574
720
|
assert.deepEqual(r.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }])
|
|
575
|
-
//
|
|
576
|
-
mkBoard(tasks)
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
assert.deepEqual(r2.blockedTouches, [])
|
|
721
|
+
// 锁真释放(holder 归档;task-muv7c8ja 后 verifying/resolved 都还持锁)后,被拦任务下一轮自动恢复可派发
|
|
722
|
+
const b2 = mkBoard(tasks)
|
|
723
|
+
b2.tasks[0].status = 'verifying'
|
|
724
|
+
const r2 = core.pickDispatch(b2, 5, 0, null)
|
|
725
|
+
assert.deepEqual(r2.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }]) // verifying 仍持锁
|
|
726
|
+
b2.tasks[0].status = 'archived'
|
|
727
|
+
const r3 = core.pickDispatch(b2, 5, 0, null)
|
|
728
|
+
assert.ok(r3.pendings.map(t => t.id).indexOf('clash') >= 0)
|
|
729
|
+
assert.deepEqual(r3.blockedTouches, [])
|
|
580
730
|
})
|
|
581
|
-
|
|
582
731
|
test('normalizeBoard: touches 脏值(非数组)收敛为空数组,缺字段任务照常', () => {
|
|
583
732
|
const legacy = { tasks: [{ id: 'a', touches: 'src/a.js' }, { id: 'b' }, { id: 'c', touches: ['src/c.js'] }] }
|
|
584
733
|
const d = core.normalizeBoard(legacy)
|
|
@@ -700,8 +849,9 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
|
|
|
700
849
|
assert.match(TEAM_SPLIT_RULE, /parentId=父卡 id/)
|
|
701
850
|
assert.match(TEAM_SPLIT_RULE, /checkParentAuto/)
|
|
702
851
|
assert.ok(src.includes("' + TASK_SIZE_CONTRACT")) // task_create 工具描述已拼接契约
|
|
703
|
-
|
|
704
|
-
assert.
|
|
852
|
+
// Team 提示词第 6 条改经 epicSplit 门禁出口注入(task-muuxcj6y):缺省 true 时逐字回到开关前形态
|
|
853
|
+
assert.ok(src.includes('splitRuleOf(epicSplitOn(String(agent.id)))'))
|
|
854
|
+
assert.equal((src.match(/withSplitHint\(\{ ok: true, task: t \}, t, cfg\(d\)\.epicSplit\)/g) || []).length, 2) // task_create 工具 + create-task RPC
|
|
705
855
|
assert.match(src, /全量\|整体\|系统级\|全面\|重构\|所有模块\|整个/) // 史诗特征词表在位
|
|
706
856
|
assert.match(src, /SPLIT_DESC_LIMIT = 500/) // 500 字符阈值在位
|
|
707
857
|
})
|
|
@@ -713,7 +863,7 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
|
|
|
713
863
|
// 必须是完整形态(session-xxxx-xxxx-...),否则全量 create-task 用例会被防线拦住。
|
|
714
864
|
const FULL_SID = 'session-test-0000-0000-000000000000'
|
|
715
865
|
function mkRpcHandlers(board, extra) {
|
|
716
|
-
const state = { handlers: {}, teamModeCache: {}, feedbackCache: {} }
|
|
866
|
+
const state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {} }
|
|
717
867
|
const tools = {} // 工具通道捕获:双通道接线测试经 __tools['task_create'].execute(...) 直调
|
|
718
868
|
const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
|
|
719
869
|
const deps = Object.assign({
|
|
@@ -963,6 +1113,142 @@ test('verify 驳回 hint:verify-task RPC + task_verify 工具 rejected 附重
|
|
|
963
1113
|
assert.equal(r4.ok, true); assert.equal('hint' in r4, false)
|
|
964
1114
|
})
|
|
965
1115
|
|
|
1116
|
+
// ===== 驳回信息全量带回(task-muvg15p5):三条驳回路径统一落 kind='rejection' 完整包 =====
|
|
1117
|
+
// 事故链:驳回信息此前只走 history(histNotes 每条截 300 字)——board_verdict 工具通道的 checks
|
|
1118
|
+
// (逐条核对证据)整段丢弃、手动驳回 comment 常空且不写 t.verification → 重派 Worker 只看到一句
|
|
1119
|
+
// 被截断的「rejected」,拿不到可执行的返工细节。修法:驳回包落 t.messages,经 buildMessages 全量
|
|
1120
|
+
// 注入 Worker prompt(每条 2000 字),history 保持不动(审计轨)。
|
|
1121
|
+
const REJ_CHECKS = '## 核对项\n- 断言①未落地(lib/core.mjs:100 无 pushRejection 调用)\n- 断言②端到端未锁(test/core.test.mjs 缺 prompt 含 checks 断言)'
|
|
1122
|
+
// 文本通道端到端 harness:真跑 poolCycle 派 Verifier run,run.result 由用例手工 resolve → 真走 settleVerifier
|
|
1123
|
+
function mkVerifierSettleDispatch(board) {
|
|
1124
|
+
var runs = {}
|
|
1125
|
+
var resolveRun = null
|
|
1126
|
+
var boardRef = { b: board }
|
|
1127
|
+
var ctx = {
|
|
1128
|
+
fs: {}, effect: function () {}, get: function () { return null }, timer: null,
|
|
1129
|
+
subagents: {
|
|
1130
|
+
list: function () { return ['mock'] },
|
|
1131
|
+
getProvider: function () { return { inheritsParentContext: false } },
|
|
1132
|
+
start: async function () { return { id: 'run-v1', result: new Promise(function (res) { resolveRun = res }), dispose: async function () {} } },
|
|
1133
|
+
},
|
|
1134
|
+
}
|
|
1135
|
+
var state = { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} }
|
|
1136
|
+
var dispatch = createDispatch(ctx, state, {
|
|
1137
|
+
rt: async function () { return boardRef.b }, wt: async function () {}, mutateLocked: async function (sid, fn) { return fn(boardRef.b) },
|
|
1138
|
+
kickCycle: function () {}, rootForSession: function () { return { id: FULL_SID } }, sessionCwd: function () { return '' },
|
|
1139
|
+
withTimeout: function (p) { return p }, runsFor: function () { return runs }, feedbackOn: function () { return true }, epicSplitOn: function () { return true },
|
|
1140
|
+
pushSysNote: function () {}, maybeNotify: function () {}, notifyTaskDone: function () {},
|
|
1141
|
+
})
|
|
1142
|
+
return { dispatch: dispatch, settle: function (text) { resolveRun({ output: [{ type: 'text', text: text }], stopReason: 'completed' }) } }
|
|
1143
|
+
}
|
|
1144
|
+
async function waitRej(t, ms) {
|
|
1145
|
+
var t0 = Date.now()
|
|
1146
|
+
while (Date.now() - t0 < (ms || 2000)) {
|
|
1147
|
+
if (t.messages.some(function (m) { return m && m.kind === 'rejection' })) return true
|
|
1148
|
+
await new Promise(function (r) { setTimeout(r, 5) })
|
|
1149
|
+
}
|
|
1150
|
+
return t.messages.some(function (m) { return m && m.kind === 'rejection' })
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
test('驳回信息全量带回①:board_verdict 工具通道 → messages 落完整驳回包(checks 全文不再丢)', async () => {
|
|
1154
|
+
const board = mkBoard([mkTask({ id: 'r1', status: 'verifying' })])
|
|
1155
|
+
const r = await mkRpcHandlers(board).__tools['board_verdict'].execute({ taskId: 'r1', verdict: 'rejected', summary: '单测未过', checks: REJ_CHECKS }, {})
|
|
1156
|
+
assert.equal(r.ok, true)
|
|
1157
|
+
const t = board.tasks[0]
|
|
1158
|
+
const rej = t.messages.filter(m => m.kind === 'rejection')
|
|
1159
|
+
assert.equal(rej.length, 1)
|
|
1160
|
+
assert.match(rej[0].text, /单测未过/)
|
|
1161
|
+
assert.ok(rej[0].text.includes('断言①未落地(lib/core.mjs:100 无 pushRejection 调用)')) // checks 全文在位(工具通道此前整段丢弃)
|
|
1162
|
+
assert.equal(rej[0].by, 'tester')
|
|
1163
|
+
assert.equal(rej[0].at, t.verification.at) // 与验收结论同一时间戳(判重口径)
|
|
1164
|
+
// 端到端:重派 Worker prompt 真的带上了 checks(「带回」的最终判据)
|
|
1165
|
+
const p = core.buildWorkerPrompt(t, '', false)
|
|
1166
|
+
assert.match(p, /\[rejection\]/)
|
|
1167
|
+
assert.ok(p.includes('断言②端到端未锁(test/core.test.mjs 缺 prompt 含 checks 断言)'))
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
test('驳回信息全量带回②:Verifier 文本结算路径 → 同构驳回包,prompt 含 checks(端到端真 settle)', async () => {
|
|
1171
|
+
const t = mkTask({ id: 'v1', status: 'verifying' })
|
|
1172
|
+
const h = mkVerifierSettleDispatch(mkBoard([t]))
|
|
1173
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
1174
|
+
assert.equal(t.status, 'verifying'); assert.ok(t.verifierRun) // 已派 Verifier(占位已换真实 run id)
|
|
1175
|
+
h.settle('REJECTED: 交付物与验收脚本不符\n\n## 测试概要\nnpm test 未跑通\n\n' + REJ_CHECKS)
|
|
1176
|
+
assert.equal(await waitRej(t), true)
|
|
1177
|
+
const rej = t.messages.filter(m => m.kind === 'rejection')
|
|
1178
|
+
assert.equal(rej.length, 1)
|
|
1179
|
+
assert.match(rej[0].text, /npm test 未跑通/)
|
|
1180
|
+
assert.ok(rej[0].text.includes('断言①未落地'))
|
|
1181
|
+
assert.equal(rej[0].by, 'run-v1')
|
|
1182
|
+
assert.equal(t.verification.verdict, 'rejected')
|
|
1183
|
+
assert.equal(t.status, 'pending') // 驳回重派(rejectCount=1 < 3)
|
|
1184
|
+
const p = core.buildWorkerPrompt(t, '', false)
|
|
1185
|
+
assert.ok(p.includes('断言②端到端未锁(test/core.test.mjs 缺 prompt 含 checks 断言)'))
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
test('驳回信息全量带回③:手动驳回双通道 → messages 落包 + t.verification 补写(空 comment 有兜底)', async () => {
|
|
1189
|
+
// RPC 通道 + comment 缺省(人工/GUI 常留空):兜底文案也要是「可执行的下一步」,不能是空消息
|
|
1190
|
+
const board = mkBoard([mkTask({ id: 'm1', status: 'verifying' })])
|
|
1191
|
+
await mkRpcHandlers(board)['verify-task']({ taskId: 'm1', verdict: 'rejected' })
|
|
1192
|
+
const t = board.tasks[0]
|
|
1193
|
+
assert.equal(t.verification.verdict, 'rejected'); assert.equal(t.verification.checks, '')
|
|
1194
|
+
assert.equal(t.verification.by, 'tester'); assert.ok(t.verification.at)
|
|
1195
|
+
const rej = t.messages.filter(m => m.kind === 'rejection')
|
|
1196
|
+
assert.equal(rej.length, 1)
|
|
1197
|
+
assert.match(rej[0].text, /驳回方未填写原因,请先自查交付物与验收脚本差距/)
|
|
1198
|
+
assert.match(core.buildWorkerPrompt(t, '', false), /驳回方未填写原因/) // 端到端:空原因也带回一句可执行兜底
|
|
1199
|
+
// 工具通道带 comment → 同构落包 + 结论字段口径与自动路径一致
|
|
1200
|
+
const board2 = mkBoard([mkTask({ id: 'm2', status: 'verifying' })])
|
|
1201
|
+
await mkRpcHandlers(board2).__tools['task_verify'].execute({ taskId: 'm2', verdict: 'rejected', comment: '缺边界断言' }, {})
|
|
1202
|
+
const t2 = board2.tasks[0]
|
|
1203
|
+
assert.equal(t2.verification.summary, '缺边界断言'); assert.equal(t2.verification.checks, '')
|
|
1204
|
+
const rej2 = t2.messages.filter(m => m.kind === 'rejection')
|
|
1205
|
+
assert.equal(rej2.length, 1)
|
|
1206
|
+
assert.match(rej2[0].text, /验收驳回 · 缺边界断言/)
|
|
1207
|
+
})
|
|
1208
|
+
|
|
1209
|
+
test('驳回信息全量带回④:approved 路径零 rejection 消息(双通道 + 文本结算,防误落)', async () => {
|
|
1210
|
+
const board = mkBoard([mkTask({ id: 'a1', status: 'verifying' })])
|
|
1211
|
+
await mkRpcHandlers(board).__tools['board_verdict'].execute({ taskId: 'a1', verdict: 'approved', summary: 'OK', checks: '## 核对项\n- 全过' }, {})
|
|
1212
|
+
assert.equal(board.tasks[0].status, 'resolved')
|
|
1213
|
+
assert.equal(board.tasks[0].messages.filter(m => m.kind === 'rejection').length, 0)
|
|
1214
|
+
const board2 = mkBoard([mkTask({ id: 'a2', status: 'verifying' })])
|
|
1215
|
+
await mkRpcHandlers(board2)['verify-task']({ taskId: 'a2', verdict: 'approved', comment: 'OK' })
|
|
1216
|
+
assert.equal(board2.tasks[0].messages.filter(m => m.kind === 'rejection').length, 0)
|
|
1217
|
+
const t = mkTask({ id: 'a3', status: 'verifying' })
|
|
1218
|
+
const h = mkVerifierSettleDispatch(mkBoard([t]))
|
|
1219
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
1220
|
+
h.settle('APPROVED: 全部通过\n\n## 测试概要\n真跑通过')
|
|
1221
|
+
await new Promise(function (r) { setTimeout(r, 20) })
|
|
1222
|
+
assert.equal(t.status, 'resolved')
|
|
1223
|
+
assert.equal(t.messages.filter(m => m.kind === 'rejection').length, 0)
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
test('pushRejection: 同事件判重(同时间戳/同前缀不双推)+ 空原因兜底文案', () => {
|
|
1227
|
+
const t = { messages: [] }
|
|
1228
|
+
assert.equal(core.pushRejection(t, '概要', '核对项全文', 'T1', 'v'), true)
|
|
1229
|
+
assert.equal(core.pushRejection(t, '概要', '核对项全文', 'T1', 'v'), false) // 同时间戳
|
|
1230
|
+
assert.equal(core.pushRejection(t, '概要', '核对项全文', 'T2', 'v'), false) // 异时间戳但正文前缀相同(同一驳回)
|
|
1231
|
+
assert.equal(core.pushRejection(t, '另一件事', '', 'T3', 'v'), true) // 不同事件照常落
|
|
1232
|
+
assert.equal(t.messages.length, 2)
|
|
1233
|
+
assert.equal(t.messages[0].kind, 'rejection'); assert.equal(t.messages[0].by, 'v')
|
|
1234
|
+
assert.equal(core.rejectionText('', ''), '(驳回方未填写原因,请先自查交付物与验收脚本差距)')
|
|
1235
|
+
assert.equal(core.rejectionText('概要', '核对'), '验收驳回 · 概要\n\n核对项:\n核对')
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
test('驳回全量带回接线(源码级):三条驳回路径都调 pushRejection + messages 通道在位', () => {
|
|
1239
|
+
const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
1240
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
1241
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
1242
|
+
assert.equal((rpc.match(/pushRejection\(t, /g) || []).length, 3) // board_verdict 工具 + verify-task RPC + task_verify 工具
|
|
1243
|
+
assert.match(dsp, /pushRejection\(t, vsecs\.verifySummary, vsecs\.checks, t\.verification\.at, String\(rec\.run\.id\)\)/)
|
|
1244
|
+
assert.equal((hostSrc().match(/pushRejection\(t, /g) || []).length, 4) // 三条路径 + Verifier 文本结算
|
|
1245
|
+
assert.match(coreSrc, /export function pushRejection/)
|
|
1246
|
+
assert.match(coreSrc, /kind: 'rejection'/)
|
|
1247
|
+
// buildMessages 现成通道在位(驳回包靠它进 prompt,不新开通道)
|
|
1248
|
+
assert.match(coreSrc, /export function buildMessages/)
|
|
1249
|
+
assert.match(coreSrc, /if \(msgs\) p \+= '\\n\\n该任务的详细消息/)
|
|
1250
|
+
})
|
|
1251
|
+
|
|
966
1252
|
test('buildVerifierPrompt: 含「立单缺调研」驳回归因条款', () => {
|
|
967
1253
|
const p = core.buildVerifierPrompt(mkTask({ id: 'tx', status: 'verifying' }), '')
|
|
968
1254
|
assert.match(p, /立单缺调研/)
|
|
@@ -993,7 +1279,8 @@ test('aggregateUsageSummary: 空任务/无 usage 任务 → 全零 + 空 Top', (
|
|
|
993
1279
|
const s = aggregateUsageSummary([mkTask({ id: 'a' }), mkTask({ id: 'b', usage: null })])
|
|
994
1280
|
assert.equal(s.total, 0); assert.equal(s.input, 0); assert.equal(s.output, 0); assert.equal(s.cacheRead, 0)
|
|
995
1281
|
assert.deepEqual(s.byModel, {}); assert.deepEqual(s.topTasks, [])
|
|
996
|
-
assert.deepEqual(
|
|
1282
|
+
assert.deepEqual(s.byDay, {}) // 日账缺省空对象(老看板/无 usage 不炸)
|
|
1283
|
+
assert.deepEqual(aggregateUsageSummary(undefined), { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byDay: {}, topTasks: [] })
|
|
997
1284
|
})
|
|
998
1285
|
|
|
999
1286
|
test('aggregateUsageSummary: 总量/输入输出缓存拆分累加 + 按模型小计合并', () => {
|
|
@@ -1003,6 +1290,49 @@ test('aggregateUsageSummary: 总量/输入输出缓存拆分累加 + 按模型
|
|
|
1003
1290
|
assert.equal(s.total, 1133); assert.equal(s.input, 105); assert.equal(s.output, 25)
|
|
1004
1291
|
assert.equal(s.cacheRead, 1000); assert.equal(s.cacheWrite, 3)
|
|
1005
1292
|
assert.deepEqual(s.byModel, { 'deepseek-flash': 1013, 'glm-5': 120 })
|
|
1293
|
+
assert.deepEqual(s.byDay, {}) // 无 byDay 且无 updatedAt → 不归任何日(宁可漏不错)
|
|
1294
|
+
})
|
|
1295
|
+
|
|
1296
|
+
// 测试侧独立复刻本地日 key(与实现同口径但各自独立写,防「实现自证」)
|
|
1297
|
+
const lk = (iso) => { const d = new Date(iso); const p2 = (n) => (n < 10 ? '0' : '') + n; return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) }
|
|
1298
|
+
// 用本地时间构造再转 ISO:任意时区下本地日都是 2026-10-02,断言与宿主时区无关
|
|
1299
|
+
const localIso = (y, mo, d, h, mi) => new Date(y, mo - 1, d, h, mi).toISOString()
|
|
1300
|
+
|
|
1301
|
+
test('aggregateUsageSummary: byDay 日账逐日合并(同日累加 / 跨日分桶)', () => {
|
|
1302
|
+
const a = mkTask({ id: 'a', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 100, runs: 2, models: {}, updatedAt: localIso(2026, 10, 2, 10, 0), byDay: { '2026-10-01': { t: 60, e: 60 }, '2026-10-02': { t: 40, e: 40 } } } })
|
|
1303
|
+
const b = mkTask({ id: 'b', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 30, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 11, 0), byDay: { '2026-10-02': { t: 30, e: 30 } } } })
|
|
1304
|
+
const s = aggregateUsageSummary([a, b])
|
|
1305
|
+
assert.deepEqual(s.byDay, { '2026-10-01': { t: 60, e: 60 }, '2026-10-02': { t: 70, e: 70 } }) // 同日 t/e 分别累加、跨日分桶
|
|
1306
|
+
assert.equal(s.total, 130) // 总量口径不变
|
|
1307
|
+
assert.deepEqual(a.usage.byDay, { '2026-10-01': { t: 60, e: 60 }, '2026-10-02': { t: 40, e: 40 } }) // 入参对象未被就地改写(纯函数)
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
test('aggregateUsageSummary: 存量任务无 byDay → 整笔归 updatedAt 的本地日(近似口径)', () => {
|
|
1311
|
+
const legacy = mkTask({ id: 'old', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 500, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 10, 30) } })
|
|
1312
|
+
const fresh = mkTask({ id: 'new', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 20, runs: 1, models: {}, updatedAt: localIso(2026, 10, 3, 9, 0), byDay: { '2026-10-03': { t: 20, e: 20 } } } })
|
|
1313
|
+
const s = aggregateUsageSummary([legacy, fresh])
|
|
1314
|
+
assert.deepEqual(s.byDay, { '2026-10-02': { t: 500, e: null }, '2026-10-03': { t: 20, e: 20 } }) // 老任务兜底只有总量(e 不可知=null)+ 新任务日账双指标
|
|
1315
|
+
assert.equal(lk(localIso(2026, 10, 2, 10, 30)), '2026-10-02') // 本地日口径自检(防 toISOString 的 UTC 错位)
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
test('aggregateUsageSummary: 本地 00:30 的 updatedAt 落在本地日(用 UTC 位移可区分的时刻钉死口径)', () => {
|
|
1319
|
+
// 东八区:本地 2026-10-02 00:30 → UTC 2026-10-01T16:30Z;若实现用 toISOString().slice(0,10)
|
|
1320
|
+
// 就会记成 10-01(跨日错位)。本条在 UTC+8 宿主上必然抓得住这种写法。
|
|
1321
|
+
const iso = localIso(2026, 10, 2, 0, 30)
|
|
1322
|
+
const s = aggregateUsageSummary([mkTask({ id: 'midnight', usage: { total: 42, models: {}, updatedAt: iso } })])
|
|
1323
|
+
assert.deepEqual(Object.keys(s.byDay), ['2026-10-02'])
|
|
1324
|
+
assert.deepEqual(s.byDay['2026-10-02'], { t: 42, e: null }) // 兜底路径只有总量:e 不可知置 null,不冒充有效值
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
test('aggregateUsageSummary: 无 updatedAt / 坏 updatedAt → 不归任何日,byDay 空对象不炸', () => {
|
|
1328
|
+
const s = aggregateUsageSummary([
|
|
1329
|
+
mkTask({ id: 'x', usage: { total: 7, models: {} } }),
|
|
1330
|
+
mkTask({ id: 'y', usage: { total: 9, models: {}, updatedAt: 'not-a-date' } }),
|
|
1331
|
+
mkTask({ id: 'z', usage: { total: 11, models: {}, byDay: null } })
|
|
1332
|
+
])
|
|
1333
|
+
assert.deepEqual(s.byDay, {})
|
|
1334
|
+
assert.equal(s.total, 27)
|
|
1335
|
+
assert.equal(s.topTasks.length, 3) // 总量/Top 不受日账缺失影响
|
|
1006
1336
|
})
|
|
1007
1337
|
|
|
1008
1338
|
test('aggregateUsageSummary: Top 任务按总量降序、最多 8 条、带 runs 计数', () => {
|
|
@@ -1014,6 +1344,86 @@ test('aggregateUsageSummary: Top 任务按总量降序、最多 8 条、带 runs
|
|
|
1014
1344
|
assert.equal(s.topTasks[0].id, 't11'); assert.equal(s.topTasks[0].title, 'T11'); assert.equal(s.topTasks[0].runs, 11)
|
|
1015
1345
|
})
|
|
1016
1346
|
|
|
1347
|
+
// ===== Token 口径对齐真实消耗:有效/缓存读分离 + run 级留账 =====
|
|
1348
|
+
test('aggregateUsageSummary: 有效消耗合计 = 输入+输出+缓存写(缓存读不计入)', () => {
|
|
1349
|
+
// 实证场景:本板累计 10.3M 里缓存读 9.7M(94%),有效消耗仅 605K——大数字虚高 17 倍。
|
|
1350
|
+
const u1 = { input: 100, output: 20, cacheRead: 1000, cacheWrite: 5, total: 1125, runs: 2, models: {} }
|
|
1351
|
+
const u2 = { input: 5, output: 5, cacheRead: 0, cacheWrite: 3, total: 13, runs: 1, models: {} }
|
|
1352
|
+
const s = aggregateUsageSummary([mkTask({ id: 'a', usage: u1 }), mkTask({ id: 'b', usage: u2 })])
|
|
1353
|
+
assert.equal(s.effective, 138) // (100+20+5) + (5+5+3)
|
|
1354
|
+
assert.equal(s.cacheRead, 1000)
|
|
1355
|
+
assert.equal(s.total, 1138)
|
|
1356
|
+
assert.equal(s.effective + s.cacheRead, s.total) // 三分量自洽:有效 + 缓存读 = 合计
|
|
1357
|
+
// 脏字段/缺字段不把 NaN 带进看板(NaN 会让工具输出的 lossless-JSON 校验拒整条结果)
|
|
1358
|
+
const s2 = aggregateUsageSummary([mkTask({ id: 'z', usage: { total: 9, models: {} } })])
|
|
1359
|
+
assert.equal(s2.effective, 0)
|
|
1360
|
+
// Top8 条目带 有效 / 缓存读 拆分(仪表盘 title 直接用)
|
|
1361
|
+
const t0 = aggregateUsageSummary([mkTask({ id: 'a', usage: u1 })]).topTasks[0]
|
|
1362
|
+
assert.equal(t0.effective, 125); assert.equal(t0.cacheRead, 1000)
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
test('aggregateUsageSummary: byDay 双指标记账({t,e} 逐日合并)+ 有效合计', () => {
|
|
1366
|
+
const a = mkTask({ id: 'a', usage: { input: 10, output: 5, cacheRead: 900, cacheWrite: 5, total: 920, runs: 2, models: {}, updatedAt: localIso(2026, 10, 2, 10, 0), byDay: { '2026-10-01': { t: 600, e: 60 }, '2026-10-02': { t: 320, e: 20 } } } })
|
|
1367
|
+
const b = mkTask({ id: 'b', usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1, total: 30, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 11, 0), byDay: { '2026-10-02': { t: 30, e: 3 } } } })
|
|
1368
|
+
const s = aggregateUsageSummary([a, b])
|
|
1369
|
+
assert.deepEqual(s.byDay, { '2026-10-01': { t: 600, e: 60 }, '2026-10-02': { t: 350, e: 23 } }) // 同日 t/e 各自累加、跨日分桶
|
|
1370
|
+
assert.equal(s.effective, 23) // (10+5+5) + (1+1+1)
|
|
1371
|
+
assert.equal(s.total, 950)
|
|
1372
|
+
assert.deepEqual(a.usage.byDay, { '2026-10-01': { t: 600, e: 60 }, '2026-10-02': { t: 320, e: 20 } }) // 纯函数:入参不被就地改写
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
test('aggregateUsageSummary: 老 number 形态 byDay 兼容(总量保留、有效值置 null 不猜)', () => {
|
|
1376
|
+
// 本轮之前的日账是裸 number(只有总量、没有逐 run 拆分)→ e 不可知必须 null,
|
|
1377
|
+
// 既不能让「近 7 天」整段消失(t 保留),也不能伪造一个有效值冒充。
|
|
1378
|
+
const legacy = mkTask({ id: 'old', usage: { input: 10, output: 5, cacheRead: 900, cacheWrite: 5, total: 920, runs: 1, models: {}, byDay: { '2026-10-01': 600 } } })
|
|
1379
|
+
const fresh = mkTask({ id: 'new', usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1, total: 30, runs: 1, models: {}, byDay: { '2026-10-02': { t: 30, e: 3 } } } })
|
|
1380
|
+
const s = aggregateUsageSummary([legacy, fresh])
|
|
1381
|
+
assert.deepEqual(s.byDay, { '2026-10-01': { t: 600, e: null }, '2026-10-02': { t: 30, e: 3 } })
|
|
1382
|
+
assert.equal(s.byDay['2026-10-01'].e, null) // 不可知就是 null(不是 0、也不是 t)
|
|
1383
|
+
// 同一天既有老形态又有新形态 → 合并后 e 仍不可知(总量照常累加,不假装算得出)
|
|
1384
|
+
const mixOld = mkTask({ id: 'm1', usage: { total: 100, models: {}, byDay: { '2026-10-03': 40 } } })
|
|
1385
|
+
const mixNew = mkTask({ id: 'm2', usage: { input: 3, output: 2, cacheRead: 50, cacheWrite: 0, total: 55, models: {}, byDay: { '2026-10-03': { t: 55, e: 5 } } } })
|
|
1386
|
+
assert.deepEqual(aggregateUsageSummary([mixOld, mixNew]).byDay['2026-10-03'], { t: 95, e: null })
|
|
1387
|
+
// 逐项合计仍按各任务 usage 精确算(不受日账形态影响)
|
|
1388
|
+
assert.equal(aggregateUsageSummary([legacy, fresh]).effective, 23) // (10+5+5)+(1+1+1)
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1391
|
+
test('aggregateUsageSummary: effective 字段与 effectiveTokens 导出一致(口径单点定义)', () => {
|
|
1392
|
+
assert.equal(effectiveTokens({ input: 3, output: 4, cacheRead: 99, cacheWrite: 5 }), 12)
|
|
1393
|
+
assert.equal(effectiveTokens({ cacheRead: 99 }), 0) // 只有缓存读 → 有效 0(缓存读不算真实消耗)
|
|
1394
|
+
assert.equal(effectiveTokens(null), 0) // 空值不炸
|
|
1395
|
+
assert.equal(effectiveTokens({ input: '3', output: 4 }), 7) // 字符串数字按数字算
|
|
1396
|
+
const s = aggregateUsageSummary([mkTask({ id: 'a', usage: { input: 3, output: 4, cacheRead: 99, cacheWrite: 5, total: 111, models: {} } })])
|
|
1397
|
+
assert.equal(s.effective, effectiveTokens({ input: 3, output: 4, cacheRead: 99, cacheWrite: 5 }))
|
|
1398
|
+
})
|
|
1399
|
+
|
|
1400
|
+
test('README 双份同步记录 Token 口径(有效消耗 / 近 7 天 / 缓存读单列 / 派发口径边界)', () => {
|
|
1401
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
1402
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
1403
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
1404
|
+
for (const s of ['今日有效消耗', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账']) {
|
|
1405
|
+
assert.ok(pkg.includes(s), 'README 应记录口径:' + s)
|
|
1406
|
+
}
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1409
|
+
test('Token run 级留账:accumulateRunUsage 把本次用量写回对应 t.runs 条目(源码级断言)', () => {
|
|
1410
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
1411
|
+
// 五分量原样写回 runs 条目:任何维度(按天/按模型/按阶段)都能从 runs 精确重建,不必回头猜
|
|
1412
|
+
assert.match(dsp, /if \(Array\.isArray\(t\.runs\)\)/)
|
|
1413
|
+
assert.match(dsp, /if \(String\(t\.runs\[ri\] && t\.runs\[ri\]\.id\) === String\(rec\.run\.id\)\)/)
|
|
1414
|
+
assert.match(dsp, /t\.runs\[ri\]\.usage = \{ input: u\.input \|\| 0, output: u\.output \|\| 0, cacheRead: u\.cacheRead \|\| 0, cacheWrite: u\.cacheWrite \|\| 0, total: u\.total \|\| 0 \}/)
|
|
1415
|
+
assert.match(dsp, /break/) // 命中即停(同一 runId 只记一次)
|
|
1416
|
+
// 倒序查找 + 找不到就跳过:runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,正常必存在
|
|
1417
|
+
assert.match(dsp, /for \(var ri = t\.runs\.length - 1; ri >= 0; ri--\)/)
|
|
1418
|
+
// 有效消耗口径单点定义(host 侧),byDay 双指标记账与它同源
|
|
1419
|
+
assert.match(dsp, /function effectiveOf\(u\) \{/)
|
|
1420
|
+
assert.match(dsp, /return \(u\.input \|\| 0\) \+ \(u\.output \|\| 0\) \+ \(u\.cacheWrite \|\| 0\)/)
|
|
1421
|
+
assert.match(dsp, /var eff = effectiveOf\(u\)/)
|
|
1422
|
+
// byDay 双指标:总 t / 有效 e 各自累加;老 number 形态脏值就地收敛成对象(不把老字段形态写坏)
|
|
1423
|
+
assert.match(dsp, /if \(!cell \|\| typeof cell !== 'object'\) cell = \{ t: Number\(cell\) \|\| 0, e: 0 \}/)
|
|
1424
|
+
assert.match(dsp, /t\.usage\.byDay\[dk\] = cell/)
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1017
1427
|
test('readRunUsage: run 不存在 / 日志目录不可读 → null(优雅降级,不抛错)', () => {
|
|
1018
1428
|
assert.equal(readRunUsage('no-such-run-' + Date.now()), null)
|
|
1019
1429
|
assert.equal(readRunUsage(''), null)
|
|
@@ -1059,6 +1469,118 @@ test('Token 消耗接线:settleRun 结算累加 + 按模型小计 + get-tasks
|
|
|
1059
1469
|
assert.match(cli, /'⛁ ' \+ fmtTokens\(t\.usage\.total\)/) // 进行中/已完成卡片显示本任务累计
|
|
1060
1470
|
})
|
|
1061
1471
|
|
|
1472
|
+
test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表盘有效消耗口径(源码级断言)', () => {
|
|
1473
|
+
const host = hostSrc()
|
|
1474
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
1475
|
+
assert.match(host, /cell\.t = \(Number\(cell\.t\) \|\| 0\) \+ u\.total/) // 结算时记一笔日账总量
|
|
1476
|
+
assert.match(host, /cell\.e = \(Number\(cell\.e\) \|\| 0\) \+ eff/) // 同一笔记一笔有效消耗
|
|
1477
|
+
assert.match(host, /var dk = localDayKey\(\)/) // 键取结算时刻本地日
|
|
1478
|
+
// 本地日口径:localDayKey 必须用本地 getters 拼(含 getFullYear/getMonth/getDate),不得用 toISOString
|
|
1479
|
+
const dispatchSrc = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
1480
|
+
const dkBody = dispatchSrc.match(/function localDayKey\(d\) \{[\s\S]{0,300}?\n \}/)
|
|
1481
|
+
assert.ok(dkBody, 'dispatch.mjs 应定义 localDayKey')
|
|
1482
|
+
assert.match(dkBody[0], /getFullYear\(\)/)
|
|
1483
|
+
assert.match(dkBody[0], /getMonth\(\) \+ 1/)
|
|
1484
|
+
assert.match(dkBody[0], /getDate\(\)/)
|
|
1485
|
+
assert.equal(/toISOString/.test(dkBody[0]), false) // UTC 会让晚间消耗落到次日
|
|
1486
|
+
assert.match(host, /if \(!t\.usage\.byDay\) t\.usage\.byDay = \{\}/) // 老任务就地补日账(不改老字段形态)
|
|
1487
|
+
assert.match(host, /lc\.t \+= u\.total; lc\.e = null; s\.byDay\[lk\] = lc/) // 存量兜底归 updatedAt 本地日(只有总量,e 不可知)
|
|
1488
|
+
// 仪表盘:大数字=今日有效消耗 + 「含缓存读共 X」小字 + 累计三分量 + 近 7 天有效值 + 口径 caption
|
|
1489
|
+
assert.match(cli, /function lastNDays\(n\)/)
|
|
1490
|
+
assert.match(cli, /var todayCell = dayOf\(byDay\[todayKey\]\)/) // 今日缺省退化(无日账不误报)
|
|
1491
|
+
assert.match(cli, /'tokens(今日有效'/) // 大数字口径 = 有效消耗
|
|
1492
|
+
assert.match(cli, /'含缓存读共 '/) // 有效 vs 总量的差额单列
|
|
1493
|
+
assert.match(cli, /'累计(本看板) 有效 '/) // 累计三分量:有效
|
|
1494
|
+
assert.match(cli, /' · 缓存读 '/) // 累计三分量:缓存读
|
|
1495
|
+
assert.match(cli, /' · 合计 '/) // 累计三分量:合计
|
|
1496
|
+
assert.match(cli, /'近 7 天(有效消耗)'/)
|
|
1497
|
+
assert.match(cli, /k\.slice\(5\) \+ ':有效 ' \+ String\(v\)/) // 条形 title:MM-DD:有效 N tok
|
|
1498
|
+
assert.match(cli, /含缓存读共 ' \+ String\(c\.t\) \+ ' tok'/) // 条形 title 补总量对照
|
|
1499
|
+
assert.match(cli, /口径:仅看板派发的 Worker\/Verifier run 消耗,不含主窗口对话/) // 口径边界明示(不含主窗口)
|
|
1500
|
+
assert.match(cli, /\/ 缓存读 ' \+ String\(x\.cacheRead \|\| 0\)/) // Top8 title 补 有效 / 缓存读 拆分
|
|
1501
|
+
assert.match(cli, /background: isToday \? C\.brand : C\.nested/) // 今天高亮 brand、其余浅底
|
|
1502
|
+
assert.match(cli, /hasDayData \? React\.createElement/) // 7 天全空不渲染该区
|
|
1503
|
+
assert.match(cli, /'(近似:老日账只有总量)'/) // e 不可知 → 标 ~ 近似,不冒充有效值
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
// ===== 调研 ROI 行 token 有效口径(task-muupnnq5)=====
|
|
1507
|
+
// 客户端 bundle 无模块系统(只注入 React),故 ROI 判定在 dashboard.js 内联为 roiTokenOf;
|
|
1508
|
+
// 这里把该函数原文切出来执行,直接断言真行为而不是断言注释。
|
|
1509
|
+
function dashboardFnOf(name) {
|
|
1510
|
+
const src = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
|
|
1511
|
+
const start = src.indexOf('function ' + name + '(')
|
|
1512
|
+
assert.ok(start >= 0, 'lib/client/dashboard.js 应定义 ' + name)
|
|
1513
|
+
const end = src.indexOf('\n function ', start + 1)
|
|
1514
|
+
return src.slice(start, end > start ? end : undefined)
|
|
1515
|
+
}
|
|
1516
|
+
function evalDashboardFn(name, deps) {
|
|
1517
|
+
const body = dashboardFnOf(name) + '\n' + (deps || []).map(dashboardFnOf).join('\n')
|
|
1518
|
+
return new Function(body + '\nreturn ' + name)()
|
|
1519
|
+
}
|
|
1520
|
+
// 造一张「已领取→已完成」的 ROI 候选卡(usage 由调用方给)
|
|
1521
|
+
function mkRoiTask(i, hasRes, usage) {
|
|
1522
|
+
const base = Date.parse('2026-01-01T00:00:00Z')
|
|
1523
|
+
return mkTask({
|
|
1524
|
+
id: 'roi' + i,
|
|
1525
|
+
status: 'resolved',
|
|
1526
|
+
createdAt: new Date(base + i * 60000).toISOString(),
|
|
1527
|
+
claimedAt: new Date(base + i * 60000 + 60000).toISOString(),
|
|
1528
|
+
resolvedAt: new Date(base + i * 60000 + 60000 + 3600000).toISOString(), // 执行恒为 1h
|
|
1529
|
+
context: hasRes ? { files: ['docs/a.md'] } : {},
|
|
1530
|
+
usage: usage || null,
|
|
1531
|
+
})
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
test('ROI token 口径:有分量字段取有效消耗(不含缓存读),与 total 明确不同(源码级实执)', () => {
|
|
1535
|
+
const roiTokenOf = evalDashboardFn('roiTokenOf')
|
|
1536
|
+
// 有效 = 输入 1000 + 输出 500 + 缓存写 200 = 1700;含缓存读的 total 竟是 91700(缓存读撑高 ~54 倍)
|
|
1537
|
+
assert.deepEqual(roiTokenOf({ input: 1000, output: 500, cacheWrite: 200, cacheRead: 90000, total: 91700 }), { tok: 1700, fallback: false })
|
|
1538
|
+
// 分量只有一项也算:缓存写单独存在时有效值就是它(不等于含缓存读的 total)
|
|
1539
|
+
assert.deepEqual(roiTokenOf({ cacheWrite: 300, cacheRead: 90000, total: 90300 }), { tok: 300, fallback: false })
|
|
1540
|
+
// 全 0 分量 + 有 total:退化为含缓存读的合计并标 fallback(老/存量结算卡的近似口径)
|
|
1541
|
+
assert.deepEqual(roiTokenOf({ input: 0, output: 0, cacheWrite: 0, cacheRead: 90000, total: 90000 }), { tok: 90000, fallback: true })
|
|
1542
|
+
assert.deepEqual(roiTokenOf({ total: 5000 }), { tok: 5000, fallback: true })
|
|
1543
|
+
// 无任何结算记录 → null(不参与均值,不拉低样本);脏值/缺 usage 同样降级
|
|
1544
|
+
assert.deepEqual(roiTokenOf({ input: 0, total: 0 }), { tok: null, fallback: false })
|
|
1545
|
+
assert.deepEqual(roiTokenOf(null), { tok: null, fallback: false })
|
|
1546
|
+
assert.deepEqual(roiTokenOf({ input: 'x', total: -5 }), { tok: null, fallback: false })
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
test('ROI 分组均值:computeStats 用有效消耗取均值,token 口径与 Token 区一致(源码级实执)', () => {
|
|
1550
|
+
const computeStats = evalDashboardFn('computeStats', ['roiTokenOf'])
|
|
1551
|
+
// 有调研 2 张:(1000+0+200)=1200 与 (300+100+0)=400 → 均值 800(若退回旧口径 total,均值会是 45850)
|
|
1552
|
+
const tasks = [
|
|
1553
|
+
mkRoiTask(0, true, { input: 1000, output: 0, cacheWrite: 200, cacheRead: 90000, total: 91200 }),
|
|
1554
|
+
mkRoiTask(1, true, { input: 300, output: 100, cacheWrite: 0, cacheRead: 0, total: 400 }),
|
|
1555
|
+
// 无调研 2 张:一张只有 total(老卡 → 兜底 + fallback 计数),一张无 usage(不参与均值)
|
|
1556
|
+
mkRoiTask(2, false, { total: 5000 }),
|
|
1557
|
+
mkRoiTask(3, false, null),
|
|
1558
|
+
]
|
|
1559
|
+
const st = computeStats(tasks)
|
|
1560
|
+
assert.ok(st.roi, '两组各 ≥1 且总样本 ≥4 时应产出 roi')
|
|
1561
|
+
assert.equal(st.roi.yes.n, 2)
|
|
1562
|
+
assert.equal(st.roi.yes.tok, 800)
|
|
1563
|
+
assert.equal(st.roi.yes.tokN, 2)
|
|
1564
|
+
assert.equal(st.roi.yes.tokFallbackN, 0)
|
|
1565
|
+
assert.equal(st.roi.no.n, 2)
|
|
1566
|
+
assert.equal(st.roi.no.tok, 5000) // 无 usage 的卡被跳过:均值就是那张老卡的 total
|
|
1567
|
+
assert.equal(st.roi.no.tokN, 1)
|
|
1568
|
+
assert.equal(st.roi.no.tokFallbackN, 1) // 老卡兜底要能被 UI 标 ~(ResearchRoiRow 读该字段)
|
|
1569
|
+
assert.equal(st.roi.no.execMs, 3600000)
|
|
1570
|
+
})
|
|
1571
|
+
|
|
1572
|
+
test('ROI 口径注释/接线:client.js 产物与 host 端判定同源(源码级断言)', () => {
|
|
1573
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
1574
|
+
assert.match(cli, /var tk = roiTokenOf\(t\.usage\)/) // ROI 行不再直接读 t.usage.total
|
|
1575
|
+
assert.equal(/var tok = \(t\.usage && typeof t\.usage\.total === 'number'/.test(cli), false)
|
|
1576
|
+
assert.match(cli, /tokN: toks\.length, tokFallbackN: fb/) // 分组均值带样本数与兜底张数
|
|
1577
|
+
assert.match(cli, /口径 = 有效消耗(输入\+输出\+缓存写,不含缓存读)/) // title 标注口径来源
|
|
1578
|
+
// host 侧同款判定(单卡口径的规范实现,client 内联以免引入模块系统)
|
|
1579
|
+
assert.deepEqual(taskEffectiveTokens({ input: 10, output: 5, cacheWrite: 2, cacheRead: 900, total: 917 }), { tok: 17, fallback: false, total: 917 })
|
|
1580
|
+
assert.deepEqual(taskEffectiveTokens({ total: 917 }), { tok: 917, fallback: true, total: 917 })
|
|
1581
|
+
assert.deepEqual(taskEffectiveTokens(null), { tok: null, fallback: false, total: null })
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1062
1584
|
// ===== 学习飞轮 v1:候选教训信号 + feedbackEnabled 开关 + 软召回 =====
|
|
1063
1585
|
test('cfg: feedbackEnabled 默认开(缺字段/脏值都算开),只有显式 false 才关', () => {
|
|
1064
1586
|
assert.equal(core.cfg({}).feedbackEnabled, true)
|
|
@@ -1120,7 +1642,7 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
|
|
|
1120
1642
|
// 候选教训两处触发:Verifier 驳回(文本通道 + 工具通道 + GUI RPC)与主窗口仲裁结论
|
|
1121
1643
|
assert.match(host, /pushRejectLesson\(d, t, vsecs\.verifySummary \|\| trimmed, t\.verification\.at\)/)
|
|
1122
1644
|
assert.match(host, /if \(!approved\) pushRejectLesson\(d, t, \(args\.summary \|\| ''\)/)
|
|
1123
|
-
assert.match(host, /if \(args\.verdict === 'rejected'\) \{ pushRejectLesson\(d, t, args\.comment\); r\.hint = REJECT_REDISPATCH_HINT \}/) // verify-task RPC
|
|
1645
|
+
assert.match(host, /if \(args\.verdict === 'rejected'\) \{ t\.verification = \{ verdict: 'rejected'[^}]*\}; pushRejection\(t, args\.comment, '', t\.verification\.at, actor\); pushRejectLesson\(d, t, args\.comment\); r\.hint = REJECT_REDISPATCH_HINT \}/) // verify-task RPC:驳回包落 messages + 候选教训 + 驳回重派 hint 同分支挂载(task-mutnj3a4 / task-muvg15p5)
|
|
1124
1646
|
assert.match(host, /pushArbitrationLesson\(d, t, escQ, answer \|\| '', arbAt\)/)
|
|
1125
1647
|
// 生成前一律过 feedbackEnabled 总开关(关掉 = 不生成、不推)
|
|
1126
1648
|
assert.equal((host.match(/if \(!cfg\(d\)\.feedbackEnabled\) return false/g) || []).length, 2)
|
|
@@ -1216,7 +1738,7 @@ test('aggregateChildStats: 空板 / 无父子关系 → 空对象(不出键)
|
|
|
1216
1738
|
assert.deepEqual(core.aggregateChildStats(d), {})
|
|
1217
1739
|
})
|
|
1218
1740
|
|
|
1219
|
-
test('aggregateChildStats: 聚合口径 total/
|
|
1741
|
+
test('aggregateChildStats: 聚合口径 total/settled/active/activeTitle', () => {
|
|
1220
1742
|
const kids = [
|
|
1221
1743
|
mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' }),
|
|
1222
1744
|
mkTask({ id: 'c2', parentId: 'epic', status: 'in-progress', title: '进行中甲' }),
|
|
@@ -1226,29 +1748,505 @@ test('aggregateChildStats: 聚合口径 total/resolved/active/activeTitle', () =
|
|
|
1226
1748
|
mkTask({ id: 'c6', parentId: 'epic', status: 'blocked' }),
|
|
1227
1749
|
]
|
|
1228
1750
|
const s = core.aggregateChildStats(kids).epic
|
|
1229
|
-
assert.equal(s.total, 6) //
|
|
1230
|
-
assert.equal(s.
|
|
1751
|
+
assert.equal(s.total, 6) // 子任务全计(含归档,本例无归档)
|
|
1752
|
+
assert.equal(s.settled, 1) // 仅 resolved(verifying 不算了结)
|
|
1753
|
+
assert.equal(s.resolved, 1) // 兼容别名同值
|
|
1231
1754
|
assert.equal(s.active, 2) // in-progress 数
|
|
1232
1755
|
assert.equal(s.activeTitle, '进行中甲') // 第一个 in-progress 子任务标题(看板顺序)
|
|
1233
1756
|
})
|
|
1234
1757
|
|
|
1235
|
-
|
|
1758
|
+
// 反馈 task-muupgfot(史诗进度归档消失):归档子任务必须计入 total + settled,否则归档一张子卡
|
|
1759
|
+
// 会让进度分母缩水(0/10 → 0/9)、分子永不前进,且全归档父卡整个从卡片上消失。
|
|
1760
|
+
test('aggregateChildStats: 归档子任务计入 total+settled(10 卡归 2 → 2/10,不再退化 0/8)', () => {
|
|
1761
|
+
const ten = []
|
|
1762
|
+
for (let i = 1; i <= 10; i++) ten.push(mkTask({ id: 'k' + i, parentId: 'epic', status: i <= 2 ? 'archived' : 'pending' }))
|
|
1763
|
+
const s10 = core.aggregateChildStats(ten).epic
|
|
1764
|
+
assert.equal(s10.total, 10) // 归档不减分母(旧口径被 continue 跳过 → 8)
|
|
1765
|
+
assert.equal(s10.settled, 2) // 归档计入分子(旧口径 → 0)
|
|
1766
|
+
assert.equal(s10.resolved, 2) // 兼容别名与 settled 同值(老前端/老断言按 resolved 读仍成立)
|
|
1767
|
+
assert.equal(s10.active, 0)
|
|
1768
|
+
|
|
1769
|
+
// 全归档父卡仍出键(史诗收尾后卡片徽章不凭空消失,留档可查)
|
|
1770
|
+
const allArch = [mkTask({ id: 'z1', parentId: 'e2', status: 'archived' }), mkTask({ id: 'z2', parentId: 'e2', status: 'archived' })]
|
|
1771
|
+
assert.deepEqual(core.aggregateChildStats(allArch).e2, { total: 2, settled: 2, resolved: 2, active: 0, activeTitle: '' })
|
|
1772
|
+
|
|
1773
|
+
// 多父卡各自成键 + 混合状态(cancelled 也属了结集:人已显式放弃该子任务范围)
|
|
1236
1774
|
const tasks = [
|
|
1237
|
-
// 父卡 p1:1 resolved + 1 archived(归档不计)+ 1 in-progress
|
|
1238
1775
|
mkTask({ id: 'a1', parentId: 'p1', status: 'resolved' }),
|
|
1239
1776
|
mkTask({ id: 'a2', parentId: 'p1', status: 'archived' }),
|
|
1240
1777
|
mkTask({ id: 'a3', parentId: 'p1', status: 'in-progress', title: 'p1活跃' }),
|
|
1241
|
-
// 父卡 p2:全部 archived → 不出键
|
|
1242
1778
|
mkTask({ id: 'b1', parentId: 'p2', status: 'archived' }),
|
|
1243
|
-
mkTask({ id: 'b2', parentId: 'p2', status: '
|
|
1244
|
-
// 父卡 p3:无活跃子任务 → activeTitle 空串
|
|
1779
|
+
mkTask({ id: 'b2', parentId: 'p2', status: 'cancelled' }),
|
|
1245
1780
|
mkTask({ id: 'd1', parentId: 'p3', status: 'pending' }),
|
|
1246
1781
|
mkTask({ id: 'd2', parentId: 'p3', status: 'resolved' }),
|
|
1247
1782
|
]
|
|
1248
1783
|
const stats = core.aggregateChildStats(tasks)
|
|
1249
|
-
assert.deepEqual(Object.keys(stats).sort(), ['p1', 'p3']) //
|
|
1250
|
-
assert.deepEqual(stats.p1, { total: 2, resolved:
|
|
1251
|
-
assert.deepEqual(stats.
|
|
1784
|
+
assert.deepEqual(Object.keys(stats).sort(), ['p1', 'p2', 'p3']) // 全归档的父卡也出键(不再「键自然消失」)
|
|
1785
|
+
assert.deepEqual(stats.p1, { total: 3, settled: 2, resolved: 2, active: 1, activeTitle: 'p1活跃' })
|
|
1786
|
+
assert.deepEqual(stats.p2, { total: 2, settled: 2, resolved: 2, active: 0, activeTitle: '' })
|
|
1787
|
+
assert.deepEqual(stats.p3, { total: 2, settled: 1, resolved: 1, active: 0, activeTitle: '' })
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
test('aggregateChildStats: in-progress/verifying/pending 未了结不计 settled;archived 永不算 active', () => {
|
|
1791
|
+
const kids = [
|
|
1792
|
+
mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', title: '甲' }),
|
|
1793
|
+
mkTask({ id: 'c2', parentId: 'epic', status: 'verifying' }),
|
|
1794
|
+
mkTask({ id: 'c3', parentId: 'epic', status: 'blocked' }),
|
|
1795
|
+
mkTask({ id: 'c4', parentId: 'epic', status: 'pending' }),
|
|
1796
|
+
mkTask({ id: 'c5', parentId: 'epic', status: 'draft' }),
|
|
1797
|
+
mkTask({ id: 'c6', parentId: 'epic', status: 'archived' }),
|
|
1798
|
+
]
|
|
1799
|
+
const s = core.aggregateChildStats(kids).epic
|
|
1800
|
+
assert.equal(s.total, 6)
|
|
1801
|
+
assert.equal(s.settled, 1) // 只有归档那张算了结;进行中/验证中/阻塞/待办/草稿都不算
|
|
1802
|
+
assert.equal(s.active, 1) // 归档不冒充在跑
|
|
1803
|
+
assert.equal(s.activeTitle, '甲')
|
|
1804
|
+
})
|
|
1805
|
+
|
|
1806
|
+
test('aggregateChildStats: 归档口径注释写明僵尸卡近似 + 旧「归档跳过」已消灭(源码级断言)', () => {
|
|
1807
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
1808
|
+
// 近似口径必须写在注释里:v1.7.1 起 archive-task 放行「无活跃 run 的 in-progress 僵尸卡」,
|
|
1809
|
+
// 故 archived 不再严格等于「曾经完成」——但归档仍是人的显式了结,进度上计入是对的
|
|
1810
|
+
assert.match(coreSrc, /archive-task 放行「无活跃 run 的 in-progress 僵尸卡」/)
|
|
1811
|
+
assert.match(coreSrc, /进度只增不减/)
|
|
1812
|
+
// 分子口径单一出处(resolved | cancelled | archived)
|
|
1813
|
+
assert.match(coreSrc, /if \(t\.status === 'resolved' \|\| t\.status === 'cancelled' \|\| t\.status === 'archived'\) \{ s\.settled\+\+; s\.resolved\+\+ \}/)
|
|
1814
|
+
// 防回归:聚合循环里不得再按 archived 跳过
|
|
1815
|
+
assert.doesNotMatch(coreSrc, /aggregateChildStats[\s\S]{0,600}?status === 'archived'\) continue/)
|
|
1816
|
+
assert.match(coreSrc, /if \(!t \|\| !isb\(t\)\) continue/)
|
|
1817
|
+
// 与父卡自动收口口径(isChildSettled,archived 不算)刻意分离,注释里写明区别
|
|
1818
|
+
assert.match(coreSrc, /与 isChildSettled(父卡自动收口口径,archived 不算)/)
|
|
1819
|
+
// 僵尸放行本身的门禁注释仍在 rpc.mjs(口径来源不悬空)
|
|
1820
|
+
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
1821
|
+
assert.match(rpcSrc, /in-progress 且无活跃 run\s+→ 放行(parentKick 僵尸态出清/)
|
|
1822
|
+
})
|
|
1823
|
+
|
|
1824
|
+
test('FamilySection: 子任务清单含归档(灰化 + 「已归档」徽章 + 沉底,不排 archived)(源码级断言)', () => {
|
|
1825
|
+
const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
|
|
1826
|
+
// 清单按 parentId 现算,**不再**附加 status !== 'archived' 过滤(反馈 task-muupgfot:归档后详情里子任务消失)
|
|
1827
|
+
assert.match(src, /var kids = state\.tasks\.filter\(function \(x\) \{ return x\.parentId === task\.id \}\)/)
|
|
1828
|
+
assert.doesNotMatch(src, /x\.parentId === task\.id && x\.status !== 'archived'/)
|
|
1829
|
+
// 沉底排序:已归档排后(稳定排序,未归档保持既有顺序)
|
|
1830
|
+
assert.match(src, /\.sort\(function \(a, b\) \{ return \(a\.status === 'archived' \? 1 : 0\) - \(b\.status === 'archived' \? 1 : 0\) \}\)/)
|
|
1831
|
+
// 行尾「已归档」徽章 + 灰化(降透明度 + 次要文字色),点击行为不变(仍可进详情看留档)
|
|
1832
|
+
assert.match(src, /'已归档'/)
|
|
1833
|
+
assert.match(src, /opacity: arch \? 0\.55 : 1/)
|
|
1834
|
+
assert.match(src, /color: arch \? C\.text2 : C\.brand/)
|
|
1835
|
+
assert.match(src, /onClick: function \(\) \{ jump\(x\.id\) \}/)
|
|
1836
|
+
// 标题行分子口径与卡片 📦 徽章同源(settled,含归档),不再用只数 resolved 的旧分子
|
|
1837
|
+
assert.match(src, /var settledN = kids\.filter\(function \(x\) \{ return x\.status === 'resolved' \|\| x\.status === 'cancelled' \|\| x\.status === 'archived' \}\)\.length/)
|
|
1838
|
+
assert.match(src, /'📦 子任务 · ' \+ settledN \+ '\/' \+ kids\.length \+ ' 已了结'/)
|
|
1839
|
+
// 卡片徽章/进度条读 settled(老宿主无该字段时回退 resolved,缺省兼容)
|
|
1840
|
+
const card = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
|
|
1841
|
+
assert.match(card, /var csDone = cs \? \(typeof cs\.settled === 'number' \? cs\.settled : cs\.resolved\) : 0/)
|
|
1842
|
+
assert.match(card, /'📦 史诗 · ' \+ csDone \+ '\/' \+ cs\.total/)
|
|
1843
|
+
assert.match(card, /Math\.round\(\(csDone \/ cs\.total\) \* 100\)/)
|
|
1844
|
+
assert.doesNotMatch(card, /cs\.resolved \+ '\/' \+ cs\.total/) // 旧读法已全部换成 csDone
|
|
1845
|
+
// 组装产物里也要有(require build-client 先跑;npm pretest 已保证)
|
|
1846
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
1847
|
+
assert.match(built, /'已归档'/)
|
|
1848
|
+
assert.match(built, /csDone/)
|
|
1849
|
+
})
|
|
1850
|
+
|
|
1851
|
+
// ===== 史诗 hooks=agent run(task-muuw4ov7):host 生命周期接线(pre 闸门 / post 收口 / 薄框架 prompt / 主窗口限定写入)=====
|
|
1852
|
+
// 设计定稿:hook 点 = 一次**真实 agent 运行**(不是声明式命令);点位可选(epic 未声明 hooks 则全链路零变化);
|
|
1853
|
+
// 薄框架 + agent 自行决策 + 歧义上报兜底;commit/push 不入任何默认形态;hooks 只许主窗口设置。
|
|
1854
|
+
const HOOK_TASK = (over) => mkTask(Object.assign({ id: 'epic', title: '史诗甲', status: 'in-progress', hooks: { pre: { enabled: true, prompt: '准备环境', state: 'idle', runId: null } } }, over || {}))
|
|
1855
|
+
|
|
1856
|
+
test('normalizeHooks: 浅校验形状(enabled 缺省 true / prompt 必填 / state 枚举 / 未知键忽略 / null 清除)', () => {
|
|
1857
|
+
const ok = core.normalizeHooks({ pre: { prompt: '准备' } })
|
|
1858
|
+
assert.deepEqual(ok.hooks.pre, { enabled: true, prompt: '准备', state: 'idle', runId: null }) // enabled 缺省 true
|
|
1859
|
+
assert.equal(core.normalizeHooks({ pre: { prompt: 'p', enabled: false } }).hooks.pre.enabled, false)
|
|
1860
|
+
assert.equal(core.normalizeHooks(null).hooks, null) // null = 清除
|
|
1861
|
+
assert.equal(core.normalizeHooks({}).hooks.pre, undefined) // 空对象 = 什么都不声明
|
|
1862
|
+
assert.deepEqual(core.normalizeHooks({ pre: null }).hooks, { pre: null }) // 显式 null 原样穿出(撤点位的意图不能在归一化阶段被吃掉)
|
|
1863
|
+
// prompt 必填(薄框架契约本体由主窗口写,空 prompt 等于没契约 → 宁早报错)
|
|
1864
|
+
assert.match(core.normalizeHooks({ pre: { prompt: ' ' } }).error, /prompt 不能为空/)
|
|
1865
|
+
assert.match(core.normalizeHooks({ pre: { prompt: 123 } }).error, /prompt 必须是字符串/)
|
|
1866
|
+
// 形状与枚举
|
|
1867
|
+
assert.match(core.normalizeHooks('x').error, /必须是对象/)
|
|
1868
|
+
assert.match(core.normalizeHooks({ pre: 'x' }).error, /hooks\.pre 必须是对象/)
|
|
1869
|
+
assert.equal(core.normalizeHooks({ pre: { prompt: 'p', state: 'running' } }).hooks.pre.state, 'running') // 合法枚举放行
|
|
1870
|
+
assert.ok(core.normalizeHooks({ pre: { prompt: 'p', state: 'bogus' } }).error, 'state 必须是 ') // 非法枚举拒绝
|
|
1871
|
+
assert.match(String(core.normalizeHooks({ pre: { prompt: 'p', state: 'bogus' } }).error), /state 必须是/)
|
|
1872
|
+
assert.equal(core.normalizeHooks({ pre: { prompt: 'p', state: 'done', runId: 'r1' } }).hooks.pre.state, 'done') // 重启后人工恢复现场
|
|
1873
|
+
// 未知键忽略(前向兼容:老 host 存下的字段不炸)
|
|
1874
|
+
assert.deepEqual(Object.keys(core.normalizeHooks({ pre: { prompt: 'p', 未知: 1 } }).hooks.pre).sort(), ['enabled', 'prompt', 'runId', 'state'])
|
|
1875
|
+
// 超长 prompt 截断到 4000
|
|
1876
|
+
assert.equal(core.normalizeHooks({ pre: { prompt: 'x'.repeat(5000) } }).hooks.pre.prompt.length, 4000)
|
|
1877
|
+
})
|
|
1878
|
+
|
|
1879
|
+
test('mergeHooks: 浅合并(未提交点位保留 / { pre: null } 只撤 pre / null 全清)', () => {
|
|
1880
|
+
const prev = { pre: { enabled: true, prompt: 'A', state: 'done', runId: null }, post: { enabled: true, prompt: 'B', state: 'idle', runId: null } }
|
|
1881
|
+
const onlyPre = core.mergeHooks(prev, core.normalizeHooks({ pre: { prompt: 'A2' } }).hooks)
|
|
1882
|
+
assert.equal(onlyPre.post.prompt, 'B') // 未提交的点位保留原值(task_update 只想改 prompt 时不必重复整条)
|
|
1883
|
+
assert.equal(onlyPre.pre.prompt, 'A2')
|
|
1884
|
+
assert.equal(onlyPre.pre.state, 'idle') // 重新提交 pre = 重置该点位状态机(人改了契约就当重跑)
|
|
1885
|
+
const dropPre = core.mergeHooks(prev, { pre: null }) // { pre: null } 撤点位
|
|
1886
|
+
assert.equal(dropPre.pre, undefined); assert.equal(dropPre.post.prompt, 'B')
|
|
1887
|
+
assert.deepEqual(core.mergeHooks(prev, null), prev) // 语义同「不提交」
|
|
1888
|
+
})
|
|
1889
|
+
|
|
1890
|
+
test('preHookGate: 未声明/未启用/done 放行;idle/running/failed 拦下(串行闸门)', () => {
|
|
1891
|
+
assert.deepEqual(core.preHookGate(null), { pass: true, reason: 'none' }) // 老 epic 无 hooks → 放行
|
|
1892
|
+
assert.deepEqual(core.preHookGate(mkTask({})), { pass: true, reason: 'none' })
|
|
1893
|
+
assert.equal(core.preHookGate(mkTask({ hooks: { pre: { enabled: false, state: 'idle' } } })).pass, true) // 未启用 → 放行
|
|
1894
|
+
assert.deepEqual(core.preHookGate(HOOK_TASK({ hooks: { pre: { enabled: true, state: 'done' } } })), { pass: true, reason: 'done' })
|
|
1895
|
+
assert.deepEqual(core.preHookGate(HOOK_TASK()), { pass: false, reason: 'idle' }) // 待跑 → 本轮不派子任务
|
|
1896
|
+
assert.deepEqual(core.preHookGate(HOOK_TASK({ hooks: { pre: { enabled: true, state: 'running' } } })), { pass: false, reason: 'running' })
|
|
1897
|
+
assert.deepEqual(core.preHookGate(HOOK_TASK({ hooks: { pre: { enabled: true, state: 'failed' } } })), { pass: false, reason: 'failed' })
|
|
1898
|
+
assert.equal(core.hookOn(HOOK_TASK(), 'pre'), true)
|
|
1899
|
+
assert.equal(core.hookOn(HOOK_TASK(), 'post'), false) // 只声明了 pre
|
|
1900
|
+
})
|
|
1901
|
+
|
|
1902
|
+
test('pickDispatch: pre 闸门未 done → 该 epic 的子任务一张都不派;done 后放行(核心回归)', () => {
|
|
1903
|
+
const epi = HOOK_TASK() // pre=idle
|
|
1904
|
+
const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
|
|
1905
|
+
const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'pending' })
|
|
1906
|
+
const other = mkTask({ id: 'solo', status: 'pending' }) // 无关卡片不受影响
|
|
1907
|
+
const d = mkBoard([epi, c1, c2, other])
|
|
1908
|
+
assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id), ['solo'])
|
|
1909
|
+
// idle → running(hook run 在跑):仍然一张都不派
|
|
1910
|
+
epi.hooks.pre.state = 'running'
|
|
1911
|
+
assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id), ['solo'])
|
|
1912
|
+
// failed(已 blocked 待人裁决):绝不自动放行
|
|
1913
|
+
epi.hooks.pre.state = 'failed'
|
|
1914
|
+
assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id), ['solo'])
|
|
1915
|
+
// done(前置准备完成):下轮起子任务正常派发
|
|
1916
|
+
epi.hooks.pre.state = 'done'
|
|
1917
|
+
assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id).sort(), ['c1', 'c2', 'solo'])
|
|
1918
|
+
})
|
|
1919
|
+
|
|
1920
|
+
test('pickDispatch: 老 epic(无 hooks)+ 只在 post 声明 → 与既有行为零变化', () => {
|
|
1921
|
+
const epi = mkTask({ id: 'epic', status: 'in-progress' }) // 无 hooks
|
|
1922
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
|
|
1923
|
+
assert.deepEqual(core.pickDispatch(mkBoard([epi, c]), 5, 0, null).pendings.map(t => t.id), ['c1'])
|
|
1924
|
+
const epi2 = mkTask({ id: 'e2', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'idle' } } })
|
|
1925
|
+
const c2 = mkTask({ id: 'c2', parentId: 'e2', status: 'pending' })
|
|
1926
|
+
assert.deepEqual(core.pickDispatch(mkBoard([epi2, c2]), 5, 0, null).pendings.map(t => t.id), ['c2']) // post 不拦前置派发
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
test('maybeAutoCloseParent 接线③:post 未收口 → 不转 verifying,只置 running + 待跑标记', () => {
|
|
1930
|
+
const epi = mkTask({ id: 'epic', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'idle' } } })
|
|
1931
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
1932
|
+
const d = mkBoard([epi, c])
|
|
1933
|
+
const p = core.maybeAutoCloseParent(d, c)
|
|
1934
|
+
assert.equal(p, epi)
|
|
1935
|
+
assert.equal(epi.status, 'in-progress') // 关键:延迟 verifying(不再直接收口)
|
|
1936
|
+
assert.equal(epi.hooks.post.pending, true) // 等 poolCycle 见标记 spawn hook-post
|
|
1937
|
+
assert.equal(epi.hooks.post.state, 'running')
|
|
1938
|
+
assert.equal(epi.resolution, undefined) // 未收口就绝不算已收口(旧实现此处会被写成 'all subtasks resolved')
|
|
1939
|
+
assert.match(epi.history[epi.history.length - 1].note, /post hook 收口未完成,延迟 verifying/)
|
|
1940
|
+
// 幂等:已在 post 收口途中重复调用仍不转 verifying
|
|
1941
|
+
assert.equal(core.maybeAutoCloseParent(d, c), epi)
|
|
1942
|
+
assert.equal(epi.status, 'in-progress')
|
|
1943
|
+
})
|
|
1944
|
+
|
|
1945
|
+
test('maybeAutoCloseParent 接线③:post 已 done → 正常转 verifying;failed → 回归安静态', () => {
|
|
1946
|
+
const mk = (state, pending) => {
|
|
1947
|
+
const e = mkTask({ id: 'epic', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: state, pending: pending } } })
|
|
1948
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
1949
|
+
return { e, d: mkBoard([e, c]), c }
|
|
1950
|
+
}
|
|
1951
|
+
const a = mk('done', undefined)
|
|
1952
|
+
assert.equal(core.maybeAutoCloseParent(a.d, a.c), a.e)
|
|
1953
|
+
assert.equal(a.e.status, 'verifying') // 收口完成 → 交人验收
|
|
1954
|
+
assert.equal(a.e.resolution, 'all subtasks resolved')
|
|
1955
|
+
const b = mk('failed', undefined)
|
|
1956
|
+
assert.equal(core.maybeAutoCloseParent(b.d, b.c), null) // 收口失败已 blocked 待人裁决,绝不自动收口
|
|
1957
|
+
assert.equal(b.e.status, 'in-progress')
|
|
1958
|
+
})
|
|
1959
|
+
|
|
1960
|
+
test('maybeAutoCloseParent: 老 epic(无 hooks)全链路零变化(防回归)', () => {
|
|
1961
|
+
const e = mkTask({ id: 'epic', status: 'in-progress' })
|
|
1962
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
1963
|
+
const d = mkBoard([e, c])
|
|
1964
|
+
assert.equal(core.maybeAutoCloseParent(d, c), e)
|
|
1965
|
+
assert.equal(e.status, 'verifying') // 未声明 post → 维持现状直接收口
|
|
1966
|
+
assert.equal(e.hooks, undefined)
|
|
1967
|
+
})
|
|
1968
|
+
|
|
1969
|
+
test('applyHookSettle 接线②:pre 完成 → done(串行闸门打开)', () => {
|
|
1970
|
+
const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '准备', state: 'running', runId: 'run-p', pending: false } } })
|
|
1971
|
+
const d = mkBoard([epi])
|
|
1972
|
+
const r = core.applyHookSettle(d, 'epic', 'pre', true, '准备完成', 'run-p', '')
|
|
1973
|
+
assert.equal(r.preDone, true)
|
|
1974
|
+
assert.equal(epi.hooks.pre.state, 'done')
|
|
1975
|
+
assert.equal(epi.hooks.pre.runId, null) // run 归零(结束即失效)
|
|
1976
|
+
assert.equal(epi.status, 'in-progress') // pre 不影响 epic 自身状态
|
|
1977
|
+
assert.match(epi.history[epi.history.length - 1].note, /hooks\.pre: running → done/)
|
|
1978
|
+
assert.equal(core.applyHookSettle(d, 'epic', 'pre', true, 'x', 'run-p', '').already, true) // 幂等
|
|
1979
|
+
})
|
|
1980
|
+
|
|
1981
|
+
test('applyHookSettle 接线②:pre 失败 → state=failed + epic blocked + 歧义上报(不自动重试)', () => {
|
|
1982
|
+
const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '准备', state: 'running', runId: 'run-p' } } })
|
|
1983
|
+
const d = mkBoard([epi])
|
|
1984
|
+
const r = core.applyHookSettle(d, 'epic', 'pre', false, '', 'run-p', '模型不可用')
|
|
1985
|
+
assert.equal(r.blocked, true)
|
|
1986
|
+
assert.equal(epi.hooks.pre.state, 'failed')
|
|
1987
|
+
assert.equal(epi.status, 'blocked')
|
|
1988
|
+
assert.match(epi.escalation.question, /hooks\.pre(前置准备)失败:模型不可用/)
|
|
1989
|
+
assert.match(epi.escalation.question, /重试.*跳过.*放弃/s)
|
|
1990
|
+
assert.match(epi.lastError, /hooks\.pre run 失败/)
|
|
1991
|
+
assert.equal(epi.messages[epi.messages.length - 1].kind, 'escalation')
|
|
1992
|
+
// 已 blocked 后子任务仍被闸门拦住(failed 不放行)
|
|
1993
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
|
|
1994
|
+
d.tasks.push(c)
|
|
1995
|
+
assert.equal(core.pickDispatch(d, 5, 0, null).pendings.length, 0)
|
|
1996
|
+
// 幂等:已被裁决前重复结算不覆盖 escalation
|
|
1997
|
+
const esc0 = epi.escalation
|
|
1998
|
+
assert.equal(core.applyHookSettle(d, 'epic', 'pre', false, '', 'run-p', 'again').already, true)
|
|
1999
|
+
assert.equal(epi.escalation, esc0)
|
|
2000
|
+
})
|
|
2001
|
+
|
|
2002
|
+
test('applyHookSettle 接线②:post 完成 → post=done + epic 转 verifying(收口完成)', () => {
|
|
2003
|
+
const epi = HOOK_TASK({ status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'running', runId: 'run-q', pending: false } } })
|
|
2004
|
+
const d = mkBoard([epi])
|
|
2005
|
+
const r = core.applyHookSettle(d, 'epic', 'post', true, '已总结', 'run-q', '')
|
|
2006
|
+
assert.equal(r.closed, true)
|
|
2007
|
+
assert.equal(epi.hooks.post.state, 'done')
|
|
2008
|
+
assert.equal(epi.status, 'verifying') // 收口完成才交人验收
|
|
2009
|
+
assert.equal(epi.resolution, 'hooks.post settled')
|
|
2010
|
+
assert.match(epi.history[epi.history.length - 1].note, /post hook 收口完成/)
|
|
2011
|
+
})
|
|
2012
|
+
|
|
2013
|
+
test('applyHookSettle 接线②:post 失败 → failed + blocked + 歧义;点位被删则只收口', () => {
|
|
2014
|
+
const epi = HOOK_TASK({ hooks: { post: { enabled: true, prompt: '收口', state: 'running', runId: 'run-q' } } })
|
|
2015
|
+
const d = mkBoard([epi])
|
|
2016
|
+
assert.equal(core.applyHookSettle(d, 'epic', 'post', false, '', 'run-q', '超时').blocked, true)
|
|
2017
|
+
assert.equal(epi.hooks.post.state, 'failed'); assert.equal(epi.status, 'blocked')
|
|
2018
|
+
assert.match(epi.escalation.question, /hooks\.post(收口)失败:超时/)
|
|
2019
|
+
// hooks 被人删除 → 只收口(already),不凭空造状态
|
|
2020
|
+
const epi2 = mkTask({ id: 'e2', status: 'in-progress' })
|
|
2021
|
+
assert.equal(core.applyHookSettle(mkBoard([epi2]), 'e2', 'post', true, '', 'r', '').already, true)
|
|
2022
|
+
assert.equal(epi2.status, 'in-progress')
|
|
2023
|
+
// epic 不存在 → null(调用方静默跳过)
|
|
2024
|
+
assert.equal(core.applyHookSettle(mkBoard([]), 'ghost', 'pre', true, '', 'r', ''), null)
|
|
2025
|
+
})
|
|
2026
|
+
|
|
2027
|
+
test('buildHookPrompt: 薄框架模板(契约 + epic 上下文 + 子任务清单 + 歧义兜底)', () => {
|
|
2028
|
+
const epi = mkTask({
|
|
2029
|
+
id: 'epic', title: '史诗甲', description: '把 X 做完',
|
|
2030
|
+
hooks: { pre: { enabled: true, prompt: '先把依赖装好', state: 'idle' } },
|
|
2031
|
+
})
|
|
2032
|
+
epi.context = { instructions: '别碰 client' }
|
|
2033
|
+
const kids = [mkTask({ id: 'c1', title: '子一', status: 'pending', pipeline: 'full' }), mkTask({ id: 'c2', title: '子二', status: 'resolved' })]
|
|
2034
|
+
const p = core.buildHookPrompt(epi, 'pre', kids)
|
|
2035
|
+
assert.match(p, /hooks=pre run/) // 角色标识
|
|
2036
|
+
assert.match(p, /pre(前置准备闸门)/)
|
|
2037
|
+
assert.match(p, /epic · 史诗甲/)
|
|
2038
|
+
assert.match(p, /把 X 做完/) // epic 描述注入
|
|
2039
|
+
assert.match(p, /别碰 client/) // epic 指引注入
|
|
2040
|
+
assert.match(p, /先把依赖装好/) // 主窗口写的运行契约
|
|
2041
|
+
assert.match(p, /- \[pending\] c1 · 子一(管线 full)/) // 子任务标题/状态清单
|
|
2042
|
+
assert.match(p, /- \[resolved\] c2 · 子二/)
|
|
2043
|
+
assert.match(p, /让这批子任务具备开跑条件/) // 薄框架:只定边界,不排具体动作
|
|
2044
|
+
assert.match(p, /由你根据上下文判断/)
|
|
2045
|
+
assert.match(p, /board_report(kind=escalate, taskId=epic/) // 歧义兜底通道
|
|
2046
|
+
assert.match(p, /不要猜测、不要硬闯/)
|
|
2047
|
+
assert.match(p, /不要默认提交(git commit)或推送(git push)/) // 红线③:commit/push 不入默认形态
|
|
2048
|
+
assert.match(p, /完成即代表 pre 闸门放行/)
|
|
2049
|
+
// post 契约:收口语义 + 明确「自行决策并自负其责」,且不含 pre 的放行文案
|
|
2050
|
+
const q = core.buildHookPrompt(epi, 'post', kids)
|
|
2051
|
+
assert.match(q, /hooks=post run/)
|
|
2052
|
+
assert.match(q, /post(收口闸门)/)
|
|
2053
|
+
assert.match(q, /把这批已完成的工作收口/)
|
|
2054
|
+
assert.match(q, /自行决策并自负其责/)
|
|
2055
|
+
assert.match(q, /史诗将自动转入 verifying/)
|
|
2056
|
+
assert.doesNotMatch(q, /完成即代表 pre 闸门放行/)
|
|
2057
|
+
// 无子任务时也有可读清单占位(不产生半截列表)
|
|
2058
|
+
assert.match(core.buildHookPrompt(epi, 'pre', []), /(暂无子任务)/)
|
|
2059
|
+
})
|
|
2060
|
+
|
|
2061
|
+
test('hooks 只许主窗口设置:create-task/update-task RPC 与 task_create/task_update 双通道角色门禁', async () => {
|
|
2062
|
+
// ① RPC 通道:子代理(resolveRoot(actor) !== actor)带 hooks 一律拒绝
|
|
2063
|
+
const child = { resolveRoot: () => 'root-other' }
|
|
2064
|
+
const b1 = mkBoard([])
|
|
2065
|
+
const r1 = await mkRpcHandlers(b1, child)['create-task']({ title: 'T', description: 'd', hooks: { pre: { prompt: 'p' } } })
|
|
2066
|
+
assert.equal(r1.ok, false); assert.match(r1.error, /hooks 仅主窗口可设/); assert.equal(b1.tasks.length, 0)
|
|
2067
|
+
// 不带 hooks 时子代理路径行为不变(老契约:RPC 通道本就主窗口驱动)
|
|
2068
|
+
const r1b = await mkRpcHandlers(mkBoard([]), child)['create-task']({ title: 'T', description: 'd' })
|
|
2069
|
+
assert.equal(r1b.ok, true)
|
|
2070
|
+
// ② RPC 通道:主窗口(resolveRoot(actor) === actor)可设 + 形状校验错误原样返回
|
|
2071
|
+
const b2 = mkBoard([])
|
|
2072
|
+
const r2 = await mkRpcHandlers(b2)['create-task']({ title: 'T', description: 'd', hooks: { pre: { prompt: '准备' } } })
|
|
2073
|
+
assert.equal(r2.ok, true)
|
|
2074
|
+
assert.equal(b2.tasks[0].hooks.pre.prompt, '准备')
|
|
2075
|
+
assert.equal(b2.tasks[0].hooks.pre.enabled, true)
|
|
2076
|
+
const r2b = await mkRpcHandlers(mkBoard([]))['create-task']({ title: 'T', description: 'd', hooks: { pre: {} } })
|
|
2077
|
+
assert.equal(r2b.ok, false); assert.match(r2b.error, /prompt 不能为空/)
|
|
2078
|
+
// ③ RPC 通道:update-task 浅合并(只改 prompt 不动 post;{ pre: null } 撤点位)
|
|
2079
|
+
const b3 = mkBoard([mkTask({ id: 'e1', hooks: { pre: { enabled: true, prompt: 'A', state: 'idle' }, post: { enabled: true, prompt: 'B', state: 'idle' } } })])
|
|
2080
|
+
const r3 = await mkRpcHandlers(b3)['update-task']({ taskId: 'e1', hooks: { pre: { prompt: 'A2' } } })
|
|
2081
|
+
assert.equal(r3.ok, true)
|
|
2082
|
+
assert.equal(b3.tasks[0].hooks.pre.prompt, 'A2'); assert.equal(b3.tasks[0].hooks.post.prompt, 'B')
|
|
2083
|
+
const r3b = await mkRpcHandlers(b3)['update-task']({ taskId: 'e1', hooks: { pre: null } })
|
|
2084
|
+
assert.equal(r3b.ok, true); assert.equal(b3.tasks[0].hooks.pre, undefined); assert.equal(b3.tasks[0].hooks.post.prompt, 'B')
|
|
2085
|
+
// ④ 工具通道:schema 有 hooks 参数 + hooks 专属门禁(子代理报「hooks 仅主窗口可设」,非通用无权限文案)
|
|
2086
|
+
const h = mkRpcHandlers(mkBoard([]))
|
|
2087
|
+
const hChild = mkRpcHandlers(mkBoard([]), child) // 子代理身份(resolveRoot 指向别处)
|
|
2088
|
+
for (const name of ['task_create', 'task_update']) {
|
|
2089
|
+
const tool = h.__tools[name]
|
|
2090
|
+
assert.ok(tool, name + ' 工具已注册')
|
|
2091
|
+
assert.ok(tool.parameters.properties.hooks, name + ' schema 含 hooks 参数')
|
|
2092
|
+
// hooks 与 draft/publish 同级(顶层属性)——曾误插进 draft/publish 属性内部(`.properties.hooks` 为空即漏检)
|
|
2093
|
+
assert.equal(tool.parameters.properties.draft && tool.parameters.properties.draft.properties && tool.parameters.properties.draft.properties.hooks, undefined, name + ' hooks 未误插进 draft 属性内')
|
|
2094
|
+
// 子代理调用带 hooks → 专属文案拒绝(先于/独立于通用「仅主窗口可用」门禁,便于定位权限边界)
|
|
2095
|
+
const resChild = await hChild.__tools[name].execute({ taskId: 'x', title: 'T', hooks: { pre: { prompt: 'p' } } })
|
|
2096
|
+
assert.equal(resChild.ok, false)
|
|
2097
|
+
assert.match(resChild.error, /hooks 仅主窗口可设/)
|
|
2098
|
+
}
|
|
2099
|
+
// ⑤ 子代理(resolveRoot 指向别处)经 update-task 带 hooks → 同一文案拒绝,且不改卡
|
|
2100
|
+
const b4 = mkBoard([mkTask({ id: 'e2', hooks: { pre: { enabled: true, prompt: 'A', state: 'idle' } } })])
|
|
2101
|
+
const r4 = await mkRpcHandlers(b4, child)['update-task']({ taskId: 'e2', hooks: { pre: { prompt: 'X' } } })
|
|
2102
|
+
assert.equal(r4.ok, false); assert.match(r4.error, /hooks 仅主窗口可设/)
|
|
2103
|
+
assert.equal(b4.tasks[0].hooks.pre.prompt, 'A')
|
|
2104
|
+
assert.equal(b4.tasks[0].hooks.pre.state, 'idle') // 状态机不被越权写入扰动
|
|
2105
|
+
})
|
|
2106
|
+
|
|
2107
|
+
// ===== hooks 接线(host 侧):pre 闸门 spawn / post 补 spawn / hook run 结算分派(直调 + 源码级)=====
|
|
2108
|
+
// poolCycle 直调 harness(带 subagents):spawnOneShot 走真实路径,捕获 provider/label/prompt;
|
|
2109
|
+
// run.result 用永不落定的 Promise(本用例只验证「谁被 spawn 了、prompt 对不对」,不触发结算)。
|
|
2110
|
+
function mkHookDispatch(board, over) {
|
|
2111
|
+
const spawned = []
|
|
2112
|
+
const runs = {}
|
|
2113
|
+
const boardRef = { b: board }
|
|
2114
|
+
const ctx = {
|
|
2115
|
+
fs: {}, effect: function () {}, get: function () { return null },
|
|
2116
|
+
timer: null,
|
|
2117
|
+
subagents: {
|
|
2118
|
+
list: () => ['mock'],
|
|
2119
|
+
getProvider: () => ({ inheritsParentContext: false }),
|
|
2120
|
+
start: async (name, req) => {
|
|
2121
|
+
spawned.push({ name, label: req.label, text: req.prompt[0].text, parent: req.parent })
|
|
2122
|
+
return { id: 'run-' + spawned.length, result: new Promise(function () {}), dispose: async function () {} }
|
|
2123
|
+
},
|
|
2124
|
+
},
|
|
2125
|
+
}
|
|
2126
|
+
const state = { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} }
|
|
2127
|
+
const dispatch = createDispatch(ctx, state, Object.assign({
|
|
2128
|
+
rt: async () => boardRef.b,
|
|
2129
|
+
wt: async () => {},
|
|
2130
|
+
mutateLocked: async (sid, fn) => fn(boardRef.b),
|
|
2131
|
+
kickCycle: () => {},
|
|
2132
|
+
rootForSession: () => ({ id: FULL_SID }),
|
|
2133
|
+
sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
|
|
2134
|
+
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
2135
|
+
}, over || {}))
|
|
2136
|
+
return { dispatch, spawned, runs, board: boardRef }
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
test('poolCycle 接线①:pre=idle → 不派子任务,改 spawn hook-pre run(prompt=薄框架+契约)', async () => {
|
|
2140
|
+
const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '先把依赖装好', state: 'idle', runId: null } } })
|
|
2141
|
+
const child = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
|
|
2142
|
+
// 第二个子任务挂在一张无 hooks 的 epic 上:它是「无关卡片」参照物,也顺带保证 epi 不被 claim
|
|
2143
|
+
// → epi 停在 in-progress(poolCycle 的空闲快进不会把整轮短路掉,hook 闸门分支真的被执行到)
|
|
2144
|
+
const epi2 = mkTask({ id: 'epic2', status: 'in-progress' })
|
|
2145
|
+
const child2 = mkTask({ id: 'z1', parentId: 'epic2', status: 'pending' })
|
|
2146
|
+
const board = mkBoard([epi, child, epi2, child2])
|
|
2147
|
+
const h = mkHookDispatch(board)
|
|
2148
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2149
|
+
const labels = h.spawned.map(s => s.label).sort()
|
|
2150
|
+
assert.deepEqual(labels, ['hook-pre:epic', 'worker:z1']) // 只 spawn hook + 无关卡片照常派,无 c1 的 Worker
|
|
2151
|
+
assert.equal(child.status, 'pending') // 串行闸门:子任务原地待命
|
|
2152
|
+
assert.equal(child2.status, 'in-progress') // 无关卡片不受闸门影响
|
|
2153
|
+
assert.equal(epi.hooks.pre.state, 'running') // 状态机推进(写在卡上,重启可恢复)
|
|
2154
|
+
assert.equal(epi.hooks.pre.pending, undefined) // pre 不用内存占用标记:幂等靠 state='running' 本身
|
|
2155
|
+
const hp = h.spawned.find(s => s.label === 'hook-pre:epic')
|
|
2156
|
+
assert.match(hp.text, /hooks=pre run/)
|
|
2157
|
+
assert.match(hp.text, /先把依赖装好/) // 主窗口契约注入
|
|
2158
|
+
// 第二轮(hook 仍在跑)不重复 spawn
|
|
2159
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2160
|
+
assert.equal(h.spawned.filter(s => s.label === 'hook-pre:epic').length, 1)
|
|
2161
|
+
})
|
|
2162
|
+
|
|
2163
|
+
test('poolCycle 接线①:pre=done → 子任务正常派发(闸门放行,不再 spawn hook)', async () => {
|
|
2164
|
+
const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '准备', state: 'done', runId: null } } })
|
|
2165
|
+
const child = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
|
|
2166
|
+
const h = mkHookDispatch(mkBoard([epi, child]))
|
|
2167
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2168
|
+
assert.equal(h.spawned.length, 1)
|
|
2169
|
+
assert.equal(h.spawned[0].label, 'worker:c1') // 派的是 Worker
|
|
2170
|
+
assert.equal(child.status, 'in-progress')
|
|
2171
|
+
})
|
|
2172
|
+
|
|
2173
|
+
test('poolCycle 接线③:post 待跑标记 → 不转 verifying,spawn hook-post run 收口', async () => {
|
|
2174
|
+
const epi = mkTask({ id: 'epic', title: '史诗甲', status: 'in-progress', hooks: { post: { enabled: true, prompt: '做收口', state: 'idle', runId: null } } })
|
|
2175
|
+
const child = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
2176
|
+
const board = mkBoard([epi, child])
|
|
2177
|
+
// 先让 core 纯函数挂上待跑标记(真实触发路径:子任务了结时 maybeAutoCloseParent 被调用)
|
|
2178
|
+
core.maybeAutoCloseParent(board, child)
|
|
2179
|
+
assert.equal(epi.status, 'in-progress') // 延迟 verifying
|
|
2180
|
+
const h = mkHookDispatch(board)
|
|
2181
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2182
|
+
assert.equal(h.spawned.length, 1)
|
|
2183
|
+
assert.equal(h.spawned[0].label, 'hook-post:epic')
|
|
2184
|
+
assert.equal(epi.status, 'in-progress') // spawn 后仍等 hook 结算才转 verifying
|
|
2185
|
+
assert.equal(epi.hooks.post.pending, false)
|
|
2186
|
+
assert.equal(epi.verifierRun, 'run-1') // 幂等占用位换成真实 run id
|
|
2187
|
+
await h.dispatch.poolCycle(FULL_SID) // 不重复 spawn
|
|
2188
|
+
assert.equal(h.spawned.length, 1)
|
|
2189
|
+
})
|
|
2190
|
+
|
|
2191
|
+
test('poolCycle 接线③:老 epic(无 hooks)→ 子任务了结仍直接 verifying,零 hook spawn(防回归)', async () => {
|
|
2192
|
+
const epi = mkTask({ id: 'epic', status: 'in-progress' })
|
|
2193
|
+
const child = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
2194
|
+
// 再来一对有 hooks 的 epic(idle pre):本轮真的会 spawn hook-pre,
|
|
2195
|
+
// 这样「hooked 场景 spawn ≥1、老场景 spawn 0」是对照组而非空跑(快进短路不会掩盖结论)
|
|
2196
|
+
const hooked = HOOK_TASK({ id: 'hooked', hooks: { pre: { enabled: true, prompt: '准备', state: 'idle' } } })
|
|
2197
|
+
const hchild = mkTask({ id: 'h1', parentId: 'hooked', status: 'pending' })
|
|
2198
|
+
const board = mkBoard([epi, child, hooked, hchild])
|
|
2199
|
+
core.maybeAutoCloseParent(board, child)
|
|
2200
|
+
const h = mkHookDispatch(board)
|
|
2201
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2202
|
+
assert.equal(epi.status, 'verifying') // 老 epic 维持现状直接收口
|
|
2203
|
+
assert.equal(hooked.hooks.pre.state, 'running') // 对照组确实跑了 hook
|
|
2204
|
+
// 老 epic 收口后进 verifying 会照常派 Verifier(既有行为未变);对照组只有 hook-pre 一条 run
|
|
2205
|
+
assert.deepEqual(h.spawned.map(s => s.label).sort(), ['hook-pre:hooked', 'verifier:epic'])
|
|
2206
|
+
assert.equal(epi.hooks, undefined)
|
|
2207
|
+
})
|
|
2208
|
+
|
|
2209
|
+
test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
|
|
2210
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
2211
|
+
// spawnOneShot:hook 角色走 buildHookPrompt(phase 由 role 决定)
|
|
2212
|
+
assert.match(dsp, /if \(role === 'hook-pre' \|\| role === 'hook-post'\) \{/)
|
|
2213
|
+
assert.match(dsp, /buildHookPrompt\(t, role === 'hook-pre' \? 'pre' : 'post', kids\)/)
|
|
2214
|
+
// settleRun:hook 分支进 settleHook(照 worker/verifier 结算模式)
|
|
2215
|
+
assert.match(dsp, /else if \(rec\.role === 'hook-pre' \|\| rec\.role === 'hook-post'\) await settleHook\(sid, rec, output, failed, errText\)/)
|
|
2216
|
+
assert.match(dsp, /applyHookSettle\(d, rec\.taskId, phase, !\(failed \|\| esc\), output, runId, errText\)/)
|
|
2217
|
+
// pre 闸门占用(state=running 即幂等占用)与 post 补 spawn(pending 标记驱动,复用 verifierRun 幂等位)
|
|
2218
|
+
assert.match(dsp, /if \(hookOn\(t, 'pre'\) && t\.hooks\.pre\.state === 'idle' && !runs\[t\.id\]\) \{/)
|
|
2219
|
+
assert.match(dsp, /hookSetState\(t, 'pre', 'running', 'system', '派发前置 hook run'\)/)
|
|
2220
|
+
assert.match(dsp, /t\.hooks\.post\.pending && !t\.verifierRun && !runs\[t\.id\]/)
|
|
2221
|
+
// spawn 失败回收:pre 退回 idle 重试 / post 保留待跑标记
|
|
2222
|
+
assert.match(dsp, /sp\.role === 'hook-pre'/)
|
|
2223
|
+
assert.match(dsp, /t\.hooks\.pre\.state === 'running'\) \{ t\.hooks\.pre\.state = 'idle'/)
|
|
2224
|
+
assert.match(dsp, /sp\.role === 'hook-post'/)
|
|
2225
|
+
// 角色口径注释:hook run 计入 activeV(不挤占 Worker 并发位但参与空闲快进判定)
|
|
2226
|
+
assert.match(dsp, /hook run 不是 Worker,不该挤占 maxWorkers 并发位/)
|
|
2227
|
+
// core:post 延迟 verifying 注释与串行闸门说明在位(设计意图可追溯)
|
|
2228
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
2229
|
+
assert.match(coreSrc, /post 延迟 verifying(hooks=agent run 接线③)/)
|
|
2230
|
+
assert.match(coreSrc, /串行闸门语义 = 「一个 epic 的子任务在 pre hook 完成之前一张都不派」/)
|
|
2231
|
+
assert.match(coreSrc, /commit\/push 不进任何默认形态/)
|
|
2232
|
+
})
|
|
2233
|
+
|
|
2234
|
+
test('hooks 主窗口限定(源码级):RPC 与工具双通道门禁 + 浅校验口径唯一在 core.normalizeHooks', () => {
|
|
2235
|
+
const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
2236
|
+
assert.match(rpc, /var HOOKS_MAIN_ONLY = 'hooks 仅主窗口可设(子代理无 hooks 权限)'/)
|
|
2237
|
+
assert.match(rpc, /function hooksDenied\(actor\) \{ return resolveRoot\(actor\) !== actor \}/)
|
|
2238
|
+
// create-task RPC:锁外角色门禁 + 锁内形状校验(与建卡同处一次持锁段)
|
|
2239
|
+
assert.match(rpc, /if \(args\.hooks !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: HOOKS_MAIN_ONLY \}/)
|
|
2240
|
+
assert.match(rpc, /var nHooks = null; if \(args\.hooks !== undefined\) \{ var hn = normalizeHooks\(args\.hooks\)/)
|
|
2241
|
+
// update-task RPC:同一门禁 + 浅合并
|
|
2242
|
+
assert.match(rpc, /if \(args\.hooks !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: HOOKS_MAIN_ONLY \}; var existsFn = existsInSession\(sid\)/)
|
|
2243
|
+
assert.match(rpc, /if \(args\.hooks !== undefined\) \{ var __he = applyHooks\(t, args\.hooks\)/)
|
|
2244
|
+
// 工具通道:两个工具的 hooks 专属门禁(在通用门禁之后,子代理拿到更准确的错误文案)
|
|
2245
|
+
assert.equal((rpc.match(/if \(args\.hooks !== undefined && resolveRoot\(__ra\) !== __ra\) return \{ ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' \}/g) || []).length, 2)
|
|
2246
|
+
// 形状校验口径唯一(normalizeHooks/mergeHooks 只在 core 定义,rpc 只解构引用)
|
|
2247
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
2248
|
+
assert.match(coreSrc, /export function normalizeHooks\(input\)/)
|
|
2249
|
+
assert.match(coreSrc, /export function mergeHooks\(prev, next\)/)
|
|
1252
2250
|
})
|
|
1253
2251
|
|
|
1254
2252
|
test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tasks 返回 childStats(源码级断言)', () => {
|
|
@@ -1257,12 +2255,16 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
|
|
|
1257
2255
|
assert.match(host, /claimApply\(d, t, 'spawn-pending', 'dispatch'\); if \(parentKickOnDispatch\(d, t\)\)/)
|
|
1258
2256
|
// get-tasks 现算 childStats(零存储)
|
|
1259
2257
|
assert.match(host, /d\.childStats = aggregateChildStats\(d\.tasks\)/)
|
|
1260
|
-
// 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
2258
|
+
// 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、
|
|
2259
|
+
// tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)扩展;
|
|
2260
|
+
// dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)
|
|
2261
|
+
assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection \} = core/)
|
|
2262
|
+
assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection \} = core/)
|
|
2263
|
+
// 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;
|
|
2264
|
+
// 但其内部已委托共享 helper maybeAutoCloseParent——单一判定口径,core.mjs 不在 hostSrc 清单,单独读)
|
|
1264
2265
|
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
1265
2266
|
assert.match(coreSrc, /if \(verdict === 'approved' && isb\(t\)\) \{ var p = checkParentAuto\(d, t\)/)
|
|
2267
|
+
assert.match(coreSrc, /export function checkParentAuto\(d, t\) \{ return maybeAutoCloseParent\(d, t\) \}/)
|
|
1266
2268
|
})
|
|
1267
2269
|
|
|
1268
2270
|
// ===== 防再发:notify 逻辑单一来源(lib/notify.mjs)=====
|
|
@@ -1270,7 +2272,7 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
|
|
|
1270
2272
|
// 函数声明提升后被头部 var x = deps.x 赋值覆盖,是永不执行的死代码——改错地方不产生效果,挪赋值顺序即引爆)。
|
|
1271
2273
|
// 此处断言六个函数的定义只存在于 notify.mjs,任何模块再长出副本即红。
|
|
1272
2274
|
test('notify 单一来源:notifyTaskDone/flushReceipts/deliverEscalation 等函数定义只存在于 lib/notify.mjs', () => {
|
|
1273
|
-
const names = ['deliverEscalation', 'notifyMainWindow', 'maybeNotify', 'notifyTaskDone', 'pushSysNote', 'flushReceipts']
|
|
2275
|
+
const names = ['deliverEscalation', 'notifyMainWindow', 'maybeNotify', 'notifyTaskDone', 'notifyDispatched', 'pushSysNote', 'flushReceipts']
|
|
1274
2276
|
const others = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
|
|
1275
2277
|
const notifySrc = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
|
|
1276
2278
|
for (const name of names) {
|
|
@@ -1283,6 +2285,218 @@ test('notify 单一来源:notifyTaskDone/flushReceipts/deliverEscalation 等
|
|
|
1283
2285
|
}
|
|
1284
2286
|
})
|
|
1285
2287
|
|
|
2288
|
+
// ===== 派发即回执(task-muuoduri):notifyDispatched 入 45s 聚合队列「🚀 已派发」区 =====
|
|
2289
|
+
// createNotify 直调 harness:mock ctx.timer 捕获 45s 窗口回调(不自动触发 → 无 45s 真等待);
|
|
2290
|
+
// root.followup 捕获投递文本;rt 按 o.board 返回看板快照(投递前状态过滤读的就是它)。
|
|
2291
|
+
function mkNotify(options) {
|
|
2292
|
+
const o = options || {}
|
|
2293
|
+
const captured = []
|
|
2294
|
+
let board = o.board || { tasks: [] }
|
|
2295
|
+
const state = { escNotifyTimers: {}, receiptBuf: {}, receiptedKeys: {}, sysNotesBuf: {} }
|
|
2296
|
+
const ctx = { timer: { timeout: () => new Promise(() => {}) } } // 窗口回调不自动触发,测试按「满 5 条」同步冲刷
|
|
2297
|
+
const root = { followup: (m) => { captured.push(m) }, whenIdle: () => Promise.resolve() }
|
|
2298
|
+
const deps = {
|
|
2299
|
+
rt: async () => board,
|
|
2300
|
+
rootForSession: () => (o.noRoot ? null : root),
|
|
2301
|
+
withTimeout: (p) => p,
|
|
2302
|
+
isDispatched: (sid, id) => !!id,
|
|
2303
|
+
}
|
|
2304
|
+
const notify = createNotify(ctx, state, deps)
|
|
2305
|
+
return { notify, state, captured, setBoard: (b) => { board = b } }
|
|
2306
|
+
}
|
|
2307
|
+
function textOf(m) { return m.content.map(c => c.text).join('\n') }
|
|
2308
|
+
const DTASK = (over) => mkTask(Object.assign({ id: 'dt1', title: '派发中的卡', status: 'in-progress', claimedBy: 'run-1', claimedAt: '2026-01-02T00:00:00Z' }, over || {}))
|
|
2309
|
+
// 顶开窗口:再入 4 条完成回执触发「满 5 条」同步冲刷(真实生产冲刷路径,无 45s 真等待)
|
|
2310
|
+
function pushFour(h) { for (const i of [1, 2, 3, 4]) h.notify.notifyTaskDone('s1', mkTask({ id: 'k' + i, title: 'K' + i, status: 'resolved', claimedBy: 'r' + i, resolvedAt: 'T' + i }), 'resolved') }
|
|
2311
|
+
|
|
2312
|
+
test('派发回执①:notifyDispatched 入队(kind=dispatched + role)→ flush 出「🚀 已派发」区', async () => {
|
|
2313
|
+
const h = mkNotify({ board: { tasks: [DTASK()] } })
|
|
2314
|
+
h.notify.notifyDispatched('s1', DTASK(), 'worker')
|
|
2315
|
+
const buf = h.state.receiptBuf.s1
|
|
2316
|
+
assert.equal(buf.items.length, 1)
|
|
2317
|
+
assert.equal(buf.items[0].kind, 'dispatched')
|
|
2318
|
+
assert.equal(buf.items[0].role, 'worker')
|
|
2319
|
+
pushFour(h)
|
|
2320
|
+
await new Promise(r => setImmediate(r))
|
|
2321
|
+
assert.equal(h.captured.length, 1)
|
|
2322
|
+
const txt = textOf(h.captured[0])
|
|
2323
|
+
assert.match(txt, /🚀 已派发 1 个:/)
|
|
2324
|
+
assert.match(txt, /派发中的卡 \(dt1\) — Worker 执行中/)
|
|
2325
|
+
assert.equal(h.captured[0].source.kind, 'plugin:dsh-agent-board')
|
|
2326
|
+
|
|
2327
|
+
// verifier 角色文案
|
|
2328
|
+
const vt = mkTask({ id: 'v1', title: '验收中的卡', status: 'in-progress', claimedBy: 'w0', verifierRun: 'v-1' })
|
|
2329
|
+
const h2 = mkNotify({ board: { tasks: [vt] } })
|
|
2330
|
+
h2.notify.notifyDispatched('s1', vt, 'verifier')
|
|
2331
|
+
pushFour(h2)
|
|
2332
|
+
await new Promise(r => setImmediate(r))
|
|
2333
|
+
assert.match(textOf(h2.captured[0]), /验收中的卡 \(v1\) — Verifier 验收中/)
|
|
2334
|
+
})
|
|
2335
|
+
|
|
2336
|
+
test('派发回执②:flush 时任务已离场(非 in-progress)→ dispatched 项被丢弃', async () => {
|
|
2337
|
+
const h = mkNotify({ board: { tasks: [mkTask({ id: 'fast', title: '10 秒探针', status: 'resolved', claimedBy: 'run-1' })] } })
|
|
2338
|
+
h.notify.notifyDispatched('s1', mkTask({ id: 'fast', title: '10 秒探针', status: 'in-progress', claimedBy: 'run-1', claimedAt: 'T' }), 'worker')
|
|
2339
|
+
assert.equal(h.state.receiptBuf.s1.items.length, 1) // 入队照常(派发时刻确实派发了)
|
|
2340
|
+
pushFour(h)
|
|
2341
|
+
await new Promise(r => setImmediate(r))
|
|
2342
|
+
assert.equal(h.captured.length, 1)
|
|
2343
|
+
const txt = textOf(h.captured[0])
|
|
2344
|
+
assert.doesNotMatch(txt, /🚀 已派发/) // 已离场的派发回执被丢弃
|
|
2345
|
+
assert.doesNotMatch(txt, /10 秒探针/) // 不与完成回执重复刷屏
|
|
2346
|
+
assert.match(txt, /✅ 完成 4 个:/) // 完成回执(主通道)照常投递
|
|
2347
|
+
})
|
|
2348
|
+
|
|
2349
|
+
test('派发回执③:完成回执与派发回执同窗口 → 合并成一条消息', async () => {
|
|
2350
|
+
const t = mkTask({ id: 'm1', title: '合并卡', status: 'in-progress', claimedBy: 'run-9', claimedAt: 'T' })
|
|
2351
|
+
const h = mkNotify({ board: { tasks: [t] } })
|
|
2352
|
+
h.notify.notifyDispatched('s1', t, 'worker') // 派发时刻
|
|
2353
|
+
h.notify.notifyTaskDone('s1', Object.assign({}, t, { status: 'resolved', resolvedAt: 'T3' }), 'resolved') // 完成时刻(同一窗口)
|
|
2354
|
+
pushFour(h)
|
|
2355
|
+
await new Promise(r => setImmediate(r))
|
|
2356
|
+
assert.equal(h.captured.length, 1) // 一条消息,不是两条
|
|
2357
|
+
const txt = textOf(h.captured[0])
|
|
2358
|
+
assert.match(txt, /回执摘要(5 条)/)
|
|
2359
|
+
assert.match(txt, /🚀 已派发 1 个:/)
|
|
2360
|
+
assert.match(txt, /✅ 完成 4 个:/)
|
|
2361
|
+
})
|
|
2362
|
+
|
|
2363
|
+
test('派发回执④:deps 未注入 notifyDispatched(老 host)→ 静默跳过,不抛错', () => {
|
|
2364
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
2365
|
+
// 回执开关(task-muuwgcro)加在 deps 注入判断之前:关掉开关与老 host 未注入两种静默路径共用同一行
|
|
2366
|
+
assert.match(dsp, /if \(c\.notifyDispatch !== false && typeof notifyDispatched === 'function'\) notifyDispatched\(sid, sp\.t, sp\.role\)/)
|
|
2367
|
+
const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
|
|
2368
|
+
assert.match(idx, /notifyDispatched: notify\.notifyDispatched/) // index.mjs 接线在位
|
|
2369
|
+
// 行为面:deps 未提供 isDispatched 时 notifyDispatched 照常入队(老 host 兼容,零依赖完成回执判定)
|
|
2370
|
+
const h = mkNotify({ board: { tasks: [DTASK()] } })
|
|
2371
|
+
assert.doesNotThrow(() => h.notify.notifyDispatched('s1', DTASK(), 'worker'))
|
|
2372
|
+
assert.equal(h.state.receiptBuf.s1.items.length, 1)
|
|
2373
|
+
})
|
|
2374
|
+
|
|
2375
|
+
// ===== 回执开关进设置(task-muuwgcro):板级 notifyDispatch/notifyDone 双布尔(缺省 true)+ 两处闸门 =====
|
|
2376
|
+
// 用户指令:「回执可以做一个开关,放到设置里」。语义:设置区「通知」小节可分别关掉「派发回执」与
|
|
2377
|
+
// 「完成回执」;歧义裁决通知(notifyMainWindow)是裁决通道不是回执,不接入开关。
|
|
2378
|
+
test('回执开关①:cfg/normalizeBoard/seed 三处缺省均为 true(老板文件与新建板行为不变)', () => {
|
|
2379
|
+
// ① 空对象(最老形态):cfg 归一为 true,不是 undefined/假值——闸门读的是 !== false
|
|
2380
|
+
const c = core.cfg({})
|
|
2381
|
+
assert.equal(c.notifyDispatch, true)
|
|
2382
|
+
assert.equal(c.notifyDone, true)
|
|
2383
|
+
// ② 老看板文件(无该字段)→ normalizeBoard 补 true(读路径兜底,UI 勾选态确定)
|
|
2384
|
+
const old = core.normalizeBoard({ version: 11, tasks: [] })
|
|
2385
|
+
assert.equal(old.notifyDispatch, true)
|
|
2386
|
+
assert.equal(old.notifyDone, true)
|
|
2387
|
+
// ③ 显式 false 原样保留;非布尔脏值收敛回 true(与 feedbackEnabled 同一归一化口径)
|
|
2388
|
+
const off = core.normalizeBoard({ notifyDispatch: false, notifyDone: false })
|
|
2389
|
+
assert.equal(off.notifyDispatch, false)
|
|
2390
|
+
assert.equal(off.notifyDone, false)
|
|
2391
|
+
assert.equal(core.cfg({ notifyDispatch: false, notifyDone: false }).notifyDispatch, false)
|
|
2392
|
+
assert.equal(core.cfg({ notifyDispatch: false, notifyDone: false }).notifyDone, false)
|
|
2393
|
+
const dirty = core.normalizeBoard({ notifyDispatch: 'no', notifyDone: 0 })
|
|
2394
|
+
assert.equal(dirty.notifyDispatch, true)
|
|
2395
|
+
assert.equal(dirty.notifyDone, true)
|
|
2396
|
+
// ④ 新建板种子显式带两个 true(seed 与 cfg 口径一致)
|
|
2397
|
+
const seeded = core.seed('s1')
|
|
2398
|
+
assert.equal(seeded.notifyDispatch, true)
|
|
2399
|
+
assert.equal(seeded.notifyDone, true)
|
|
2400
|
+
})
|
|
2401
|
+
|
|
2402
|
+
test('回执开关②:notifyDispatch=false → spawn 成功也不入派发回执(真跑 poolCycle + 真 spawnOneShot)', async () => {
|
|
2403
|
+
const calls = []
|
|
2404
|
+
// 真 spawnOneShot 需要一个可用 provider:start 返回永不结算的 run(派发回执只看 spawn 成功,不看结局)
|
|
2405
|
+
const ctxOver = { subagents: { list: () => ['p1'], getProvider: () => ({ inheritsParentContext: false }), start: async () => ({ id: 'run-1', dispose() {}, result: new Promise(function () {}) }) } }
|
|
2406
|
+
function run(flag) {
|
|
2407
|
+
const board = Object.assign(mkBoard([mkTask({ id: 'dt1', title: '被派的卡' })]), { maxWorkers: 3, notifyDispatch: flag })
|
|
2408
|
+
return mkDispatch(board, {
|
|
2409
|
+
rootForSession: () => ({ id: FULL_SID }),
|
|
2410
|
+
notifyDispatched: (sid, t, role) => calls.push({ sid: sid, id: t.id, role: role }),
|
|
2411
|
+
}, ctxOver)
|
|
2412
|
+
}
|
|
2413
|
+
// 关:spawn 照常成功(占位 claim + 历史留档 + run id 回写,多次写盘),但派发回执一次都不入队
|
|
2414
|
+
const off = run(false)
|
|
2415
|
+
await off.dispatch.poolCycle(FULL_SID)
|
|
2416
|
+
assert.equal(calls.length, 0, 'notifyDispatch=false 时不得入派发回执')
|
|
2417
|
+
assert.ok(off.counters.writes > 1, '派发确实发生(不是因未派发才没回执)')
|
|
2418
|
+
// 开(缺字段 → cfg 默认 true):同一路径照常回执,证明闸门没误伤正常路径
|
|
2419
|
+
calls.length = 0
|
|
2420
|
+
const on = run(true)
|
|
2421
|
+
await on.dispatch.poolCycle(FULL_SID)
|
|
2422
|
+
assert.deepEqual(calls, [{ sid: FULL_SID, id: 'dt1', role: 'worker' }])
|
|
2423
|
+
})
|
|
2424
|
+
|
|
2425
|
+
test('回执开关③b:notifyDone=false → worker 三连败转 blocked 也不入完成回执(真跑结算路径)', async () => {
|
|
2426
|
+
const done = []
|
|
2427
|
+
// run 立即失败(Promise.reject)→ settleRun 走失败分支 → retryCount 2→3 → blocked(状态机不受开关影响)
|
|
2428
|
+
const ctxOver = { subagents: { list: () => ['p1'], getProvider: () => ({ inheritsParentContext: false }), start: async () => ({ id: 'run-1', dispose() {}, result: Promise.reject(new Error('boom')) }) } }
|
|
2429
|
+
async function run(flag) {
|
|
2430
|
+
done.length = 0
|
|
2431
|
+
const runs = {} // 稳定 runs 表:settleRun 靠 runsFor(sid)[taskId] === rec 认领本次 run
|
|
2432
|
+
const board = Object.assign(mkBoard([mkTask({ id: 'dt2', title: '会失败的卡', retryCount: 2 })]), { maxWorkers: 3, notifyDone: flag })
|
|
2433
|
+
const h = mkDispatch(board, {
|
|
2434
|
+
rootForSession: () => ({ id: FULL_SID }),
|
|
2435
|
+
runsFor: () => runs,
|
|
2436
|
+
notifyTaskDone: (sid, t, kind) => done.push({ id: t.id, kind: kind, status: t.status }),
|
|
2437
|
+
}, ctxOver)
|
|
2438
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2439
|
+
for (let i = 0; i < 3; i++) await new Promise(r => setImmediate(r)) // 结算链(含 closeRunHistory/usage)全在微任务里
|
|
2440
|
+
return { h, board }
|
|
2441
|
+
}
|
|
2442
|
+
// 关:照常 blocked(卡该阻塞就阻塞),只是一条完成回执都不发
|
|
2443
|
+
const off = await run(false)
|
|
2444
|
+
assert.equal(off.board.tasks[0].status, 'blocked')
|
|
2445
|
+
assert.deepEqual(done, [])
|
|
2446
|
+
// 开:同一路径照常回执(证明闸门没误伤完成回执主通道)
|
|
2447
|
+
const on = await run(true)
|
|
2448
|
+
assert.equal(on.board.tasks[0].status, 'blocked')
|
|
2449
|
+
assert.deepEqual(done, [{ id: 'dt2', kind: 'blocked', status: 'blocked' }])
|
|
2450
|
+
})
|
|
2451
|
+
|
|
2452
|
+
test('回执开关③/④:完成/阻塞回执闸门在结算调用方;歧义裁决通道零开关(源码级断言)', () => {
|
|
2453
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
2454
|
+
const noti = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
|
|
2455
|
+
// 完成回执:三个结算函数(worker/verifier/hook)都在同一次持锁回调里取 notifyDone(零额外读盘)
|
|
2456
|
+
assert.equal((dsp.match(/doneOn = cfg\(d\)\.notifyDone !== false/g) || []).length, 3)
|
|
2457
|
+
assert.equal((dsp.match(/if \(doneOn && /g) || []).length, 5) // worker 2 + verifier 2 + hook-closed 1
|
|
2458
|
+
assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'resolved'\) notifyTaskDone\(sid, result\.task, 'resolved'\)/)
|
|
2459
|
+
assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'blocked'\) notifyTaskDone\(sid, result\.task, 'blocked'\)/)
|
|
2460
|
+
assert.match(dsp, /if \(result\.blocked\) \{ maybeNotify\(sid, result\.task\); if \(doneOn\) notifyTaskDone\(sid, result\.task, 'blocked'\) \}/)
|
|
2461
|
+
// 闸门默认 true:回调未跑到也不漏报(宁可多报)
|
|
2462
|
+
assert.equal((dsp.match(/var doneOn = true/g) || []).length, 3)
|
|
2463
|
+
// ④ 歧义通道零开关:通知层根本读不到板级配置(cfg 未引入),去注释后的上报代码段不含两个开关名
|
|
2464
|
+
assert.doesNotMatch(noti, /cfg\(/)
|
|
2465
|
+
const escBlock = noti.slice(noti.indexOf('// 歧义上报通知'), noti.indexOf('// ===== 任务回执通知'))
|
|
2466
|
+
assert.ok(escBlock.length > 200, '上报段切片成功(锚点注释在位)')
|
|
2467
|
+
const escCode = escBlock.split('\n').filter(l => !/^\s*\/\//.test(l)).join('\n')
|
|
2468
|
+
assert.doesNotMatch(escCode, /notifyDispatch|notifyDone/)
|
|
2469
|
+
// 三个结算点的歧义通知调用(maybeNotify)都不带任何开关闸门
|
|
2470
|
+
assert.equal((dsp.match(/maybeNotify\(sid, result\.task\)/g) || []).length, 3)
|
|
2471
|
+
})
|
|
2472
|
+
|
|
2473
|
+
test('回执开关⑤:设置弹层「通知」小节两行开关 + set-board-config 读写通道(源码 + RPC 行为)', async () => {
|
|
2474
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
2475
|
+
// UI:小节标题 + 两行开关文案 + 各自 RPC 键(勾选态缺字段=开,与 host 缺省一致)
|
|
2476
|
+
assert.match(cli, /'通知'/)
|
|
2477
|
+
assert.match(cli, /'⚡ 派发回执(任务被 Worker\/Verifier 领走时播报)'/)
|
|
2478
|
+
assert.match(cli, /'✅ 完成回执(任务完成或阻塞时聚合播报)'/)
|
|
2479
|
+
assert.match(cli, /checked: props\.notifyDispatch !== false, onChange: function \(e\) \{ rpc\('set-board-config', \{ key: 'notifyDispatch', value: e\.target\.checked \}\)/)
|
|
2480
|
+
assert.match(cli, /checked: props\.notifyDone !== false, onChange: function \(e\) \{ rpc\('set-board-config', \{ key: 'notifyDone', value: e\.target\.checked \}\)/)
|
|
2481
|
+
assert.match(cli, /歧义裁决通知不受这两个开关影响/)
|
|
2482
|
+
// 透传链路:state 读取(老 host 缺字段=开)→ TopPanel useState → PoolCfgPopover props
|
|
2483
|
+
assert.match(cli, /state\.notifyDispatch = !\(d && d\.notifyDispatch === false\)/)
|
|
2484
|
+
assert.match(cli, /state\.notifyDone = !\(d && d\.notifyDone === false\)/)
|
|
2485
|
+
assert.match(cli, /useState\(state\.notifyDispatch\)/)
|
|
2486
|
+
assert.match(cli, /feedbackEnabled: fbEnabled, notifyDispatch: ndOn, notifyDone: nnOn/)
|
|
2487
|
+
// host 通道:set-board-config 白名单两键布尔原样存 + get-tasks 显式透出确定布尔值
|
|
2488
|
+
const board = mkBoard([])
|
|
2489
|
+
const h = mkRpcHandlers(board)
|
|
2490
|
+
await h['set-board-config']({ key: 'notifyDispatch', value: false })
|
|
2491
|
+
await h['set-board-config']({ key: 'notifyDone', value: false })
|
|
2492
|
+
assert.equal(board.notifyDispatch, false)
|
|
2493
|
+
assert.equal(board.notifyDone, false)
|
|
2494
|
+
await h['set-board-config']({ key: 'notifyDispatch', value: true })
|
|
2495
|
+
const got = await h['get-tasks']({})
|
|
2496
|
+
assert.equal(got.notifyDispatch, true)
|
|
2497
|
+
assert.equal(got.notifyDone, false)
|
|
2498
|
+
})
|
|
2499
|
+
|
|
1286
2500
|
// ===== 调研门禁·UI:无调研徽章 + 详情注入清单 + 创建表单 warning 展示(源码级断言)=====
|
|
1287
2501
|
test('调研门禁 UI 接线:卡片「⚠️ 无调研」徽章 + 详情「调研注入」清单 + 创建表单 warning 黄行(源码级断言)', () => {
|
|
1288
2502
|
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
@@ -1463,11 +2677,12 @@ test('touchSession:不完整短 id 不注册进已知会话集合(幻影板
|
|
|
1463
2677
|
})
|
|
1464
2678
|
|
|
1465
2679
|
// poolCycle 直调 harness:ctx 不给 timer(15s 心跳 IIFE 跳过),deps 全 mock,计数读/写/日志
|
|
1466
|
-
|
|
2680
|
+
// ctxOver(第三参,可选):覆盖 ctx 面(如注入 subagents 让真 spawnOneShot 跑起来——回执开关行为测试用)
|
|
2681
|
+
function mkDispatch(board, over, ctxOver) {
|
|
1467
2682
|
const counters = { reads: 0, writes: 0, errs: [] }
|
|
1468
2683
|
const dispatch = createDispatch(
|
|
1469
2684
|
// 创建期无条件触达:effect(卸载清理注册)/ get('systemPrompt' 引导段);不给 timer/agents/subagents
|
|
1470
|
-
{ fs: {}, effect: function () {}, get: function () { return null } },
|
|
2685
|
+
Object.assign({ fs: {}, effect: function () {}, get: function () { return null } }, ctxOver || {}),
|
|
1471
2686
|
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} },
|
|
1472
2687
|
Object.assign({
|
|
1473
2688
|
rt: async () => { counters.reads++; return board },
|
|
@@ -1538,5 +2753,295 @@ test('no-root 刷屏根治接线断言(源码级):闸门/防线/兜底注
|
|
|
1538
2753
|
assert.match(ses, /sid !== 'unknown' && isFullSessionId\(sid\)\) knownSessions\[sid\]/)
|
|
1539
2754
|
assert.match(rpc, /import \{ isFullSessionId \} from '\.\/session\.mjs'/)
|
|
1540
2755
|
// create-task handler 校验在 mutateLocked 之前(拒绝时不建板)
|
|
1541
|
-
|
|
2756
|
+
// 窗口放宽到 700:幻影板防线与 return mutateLocked 之间新增了 hooks 角色门禁(本批 hooks=agent run;
|
|
2757
|
+
// hooks 是高权限入口,只许主窗口设置),防线本身仍紧随 handler 开头
|
|
2758
|
+
assert.match(rpc, /handle\('create-task', async function \(args\) \{ var sid = rpcSessionId\(args\);[\s\S]{0,700}if \(!isFullSessionId\(sid\)\) return \{ ok: false, error: 'sessionId 不完整[\s\S]{0,300}return mutateLocked/)
|
|
2759
|
+
})
|
|
2760
|
+
|
|
2761
|
+
// ===== isRoot 蝶变防抖(task-muupr8ld,反馈 n-muuerxv9ijxs)=====
|
|
2762
|
+
// 根因:host get-tasks 现算 isRoot(agents.roots() 含本 sid),生成开始/结束瞬间 agents 树重建有瞬态窗口
|
|
2763
|
+
// 返回假 false;旧口径一次 false 就强收抽屉(state.open=false)→「看板在生成状态切换时突然消失」。
|
|
2764
|
+
// 修法:客户端防抖——曾确认 true 的会话需连续 3 次 false 才收;从未 true 的子代理会话即时收起;
|
|
2765
|
+
// 三处消费点(强收/按钮/面板)+ 活动心跳省流统一读 isRootStable;会话切换重置防抖态。
|
|
2766
|
+
test('isRoot 蝶变防抖(源码级):计数器 + 连续 3 次阈值 + everTrue 门锁 + 新会话即时收紧', () => {
|
|
2767
|
+
const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
|
|
2768
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
2769
|
+
// ① 防抖态三字段入 state(everTrue/连续 false 计数/稳定值)
|
|
2770
|
+
assert.match(ksrc, /isRoot: true, isRootEverTrue: false, isRootFalseN: 0, isRootStable: true/)
|
|
2771
|
+
// ② 阈值常量 = 3(host 3s 心跳 ≈ 9s),注释写明为什么防抖/为什么子代理不防抖
|
|
2772
|
+
assert.match(ksrc, /var IS_ROOT_FALSE_LIMIT = 3/)
|
|
2773
|
+
assert.match(ksrc, /为什么防抖:host rpc\.mjs L97 的 isRoot 是每次 get-tasks 现算的/)
|
|
2774
|
+
assert.match(ksrc, /为什么子代理会话不防抖:everTrue=false 的会话(新打开的子代理)立即 isRootStable=false/)
|
|
2775
|
+
// ③ 防抖器本体:true → 清零+everTrue+stable;从未 true → 即时 false(门锁);everTrue 则累计到阈值才翻
|
|
2776
|
+
assert.match(ksrc, /function applyIsRoot\(rawIsRoot\) \{/)
|
|
2777
|
+
assert.match(ksrc, /if \(rawIsRoot\) \{[\s\S]{0,400}state\.isRootEverTrue = true/)
|
|
2778
|
+
assert.match(ksrc, /state\.isRootFalseN = 0[\s\S]{0,80}state\.isRootStable = true/)
|
|
2779
|
+
assert.match(ksrc, /else if \(!state\.isRootEverTrue\) \{[\s\S]{0,120}state\.isRootStable = false/)
|
|
2780
|
+
assert.match(ksrc, /state\.isRootFalseN\+\+[\s\S]{0,200}if \(state\.isRootFalseN >= IS_ROOT_FALSE_LIMIT\) state\.isRootStable = false/)
|
|
2781
|
+
// ④ 轮询只把原始值喂给防抖器(不再直写 state.isRoot)
|
|
2782
|
+
assert.match(ksrc, /applyIsRoot\(!d \|\| d\.isRoot !== false\)/)
|
|
2783
|
+
assert.doesNotMatch(ksrc, /state\.isRoot = !d \|\| d\.isRoot !== false/)
|
|
2784
|
+
// ⑤ 三处消费点 + 活动心跳省流统一读 isRootStable(不得再读单次 isRoot 原始值)
|
|
2785
|
+
assert.match(ksrc, /if \(!state\.isRootStable && state\.open\) \{ state\.open = false; state\.detailId = null \}/) // L303 强收
|
|
2786
|
+
assert.match(ksrc, /if \(!state\.sessionId \|\| !state\.isRootStable \|\| !state\.open\) return/) // 省流轮询
|
|
2787
|
+
assert.match(ksrc, /setIsOpen\(state\.open\); setIsRoot\(state\.isRootStable\)/) // 按钮
|
|
2788
|
+
assert.match(ksrc, /if \(!isRoot\) return null \/\/ 子代理会话不显示看板入口(读 isRootStable/)
|
|
2789
|
+
assert.match(ksrc, /if \(!state\.isRootStable\) return null \/\/ 子代理会话不渲染看板面板(读 isRootStable/)
|
|
2790
|
+
assert.doesNotMatch(ksrc, /state\.isRoot === false/) // 旧口径已根除(消费点不再看单次值)
|
|
2791
|
+
// ⑥ 会话切换重置防抖态(旧会话的"曾确认 true"不得漂到新会话)
|
|
2792
|
+
assert.match(ksrc, /state\.isRootEverTrue = false; state\.isRootFalseN = 0; state\.isRootStable = false; notify\(\); fetchTasks\(\); fetchChildren\(\)/)
|
|
2793
|
+
// ⑦ 产物体重组装:断言在源码与 lib/client.js 产物上同口径
|
|
2794
|
+
assert.match(cli, /var IS_ROOT_FALSE_LIMIT = 3/)
|
|
2795
|
+
assert.match(cli, /isRootEverTrue: false, isRootFalseN: 0, isRootStable: true/)
|
|
2796
|
+
assert.equal((cli.match(/applyIsRoot\(/g) || []).length, 2) // 定义 1 + 轮询调用 1
|
|
2797
|
+
})
|
|
2798
|
+
|
|
2799
|
+
test('isRoot 蝶变防抖:host 侧注释指回客户端防抖,且 host 行为未改(仍现算 isRoot)', () => {
|
|
2800
|
+
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
2801
|
+
// 指针注释:说明瞬态假 false + 防抖落在客户端 kernel.js applyIsRoot
|
|
2802
|
+
assert.match(rpcSrc, /host 不改行为(保持"现算真相"),防抖在客户端:kernel\.js applyIsRoot/)
|
|
2803
|
+
assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
|
|
2804
|
+
// 行为未改:isRoot 仍按 agents.roots() 现算(不缓存、不防抖、不落盘)
|
|
2805
|
+
assert.match(rpcSrc, /var __roots = __ag\.roots\(\)[\s\S]{0,200}d\.isRoot = __rids\.indexOf\(sid\) >= 0/)
|
|
2806
|
+
})
|
|
2807
|
+
|
|
2808
|
+
// ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
|
|
2809
|
+
// 依赖卡 task-muuw4ov7 落地的真实字段契约(本文件上方 hooks 测试段即其口径):
|
|
2810
|
+
// epic.hooks = { pre: { enabled, prompt, state, runId, pending? }, post: 同构 };state ∈ idle|running|done|failed;
|
|
2811
|
+
// hook run 记在 epic.runs[](role 'hook-pre'/'hook-post',{ role, id, at, endedAt, outcome, model });
|
|
2812
|
+
// update-task RPC 的 hooks 字段浅合并,normalizeHooks 对缺省 state 归零成 idle(故 UI 必须透传 state/runId);
|
|
2813
|
+
// hooks 只许主窗口设置(UI 不做权限预判,失败原因原样回显)。
|
|
2814
|
+
test('hooks 卡片相位徽章(源码级):三态文案 + err 色 + 无 hooks 零渲染', () => {
|
|
2815
|
+
const card = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
|
|
2816
|
+
// 相位判定唯一出处:hookBadgeOf(读 t.hooks,未声明直接 null = 老卡零渲染)
|
|
2817
|
+
assert.match(card, /function hookBadgeOf\(t\) \{/)
|
|
2818
|
+
assert.match(card, /var h = t && t\.hooks\n if \(!h\) return null/)
|
|
2819
|
+
// 三个相位文案 + 徽章优先级:failed(pre|post)> pre running > post running > 不渲染
|
|
2820
|
+
assert.match(card, /'⚠️ hook 失败待裁决'/)
|
|
2821
|
+
assert.match(card, /'⏳ 前置准备中'/)
|
|
2822
|
+
assert.match(card, /'🧪 收尾中'/)
|
|
2823
|
+
assert.match(card, /if \(\(pre && pre\.state === 'failed'\) \|\| \(post && post\.state === 'failed'\)\) \{/)
|
|
2824
|
+
assert.match(card, /if \(pre && pre\.state === 'running'\) return \{ txt: '⏳ 前置准备中'/)
|
|
2825
|
+
assert.match(card, /if \(post && post\.state === 'running'\) return \{ txt: '🧪 收尾中'/)
|
|
2826
|
+
assert.match(card, /return null\n \}/) // 末尾兜底:idle / done 不占位
|
|
2827
|
+
// 只有「已声明且 enabled」的点位参与相位判定(停用的点位不该报 running/failed)
|
|
2828
|
+
assert.match(card, /var pre = \(h\.pre && h\.pre\.enabled\) \? h\.pre : null/)
|
|
2829
|
+
assert.match(card, /var post = \(h\.post && h\.post\.enabled\) \? h\.post : null/)
|
|
2830
|
+
// 失败徽章走 err 色(与既有 escalation 视觉同源)
|
|
2831
|
+
assert.match(card, /return \{ txt: '⚠️ hook 失败待裁决', color: C\.err,/)
|
|
2832
|
+
// 挂载点:与「📦 史诗 · x/y」并存(史诗卡限定,相位是附加过程态而非替代进度)
|
|
2833
|
+
assert.match(card, /epic \? hookPhaseBadge\(t\) : null,/)
|
|
2834
|
+
assert.match(card, /'📦 史诗 · ' \+ csDone \+ '\/' \+ cs\.total/)
|
|
2835
|
+
// 组装产物里也要有(require build-client 先跑;npm pretest 已保证)
|
|
2836
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
2837
|
+
for (const s of ['⏳ 前置准备中', '🧪 收尾中', '⚠️ hook 失败待裁决']) assert.ok(built.includes(s), '产物应含相位文案:' + s)
|
|
2838
|
+
})
|
|
2839
|
+
|
|
2840
|
+
test('hooks 详情页编辑区(源码级):开关 + prompt + state/耗时 + 失败跳裁决 + run 跳会话 + 保存通道', () => {
|
|
2841
|
+
const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
|
|
2842
|
+
// ① 挂载与门禁:史诗(有子任务)或已声明 hooks 才渲染,普通老卡整块不出现
|
|
2843
|
+
assert.match(src, /function HooksSection\(props\) \{/)
|
|
2844
|
+
assert.match(src, /var kids = state\.tasks\.filter\(function \(x\) \{ return x\.parentId === task\.id \}\)/)
|
|
2845
|
+
assert.match(src, /var declared = !!\(kHooks && \(kHooks\.pre \|\| kHooks\.post\)\)/)
|
|
2846
|
+
assert.match(src, /if \(kids\.length === 0 && !declared\) return null/)
|
|
2847
|
+
// key=task.id 挂载:切换任务重挂组件,编辑缓冲不串卡
|
|
2848
|
+
assert.match(src, /React\.createElement\(HooksSection, \{ key: task\.id, task: task \}\)/)
|
|
2849
|
+
// ② 两行同构:enabled 开关(原生 checkbox)+ prompt 文本框(薄框架语义写进 placeholder)
|
|
2850
|
+
assert.match(src, /phaseRow\('pre', '⏳ 前置(pre)'/)
|
|
2851
|
+
assert.match(src, /phaseRow\('post', '🧪 收尾(post)'/)
|
|
2852
|
+
assert.match(src, /type: 'checkbox', checked: !!b\.enabled, onChange: function \(e\) \{ setPhase\(ph, \{ enabled: e\.target\.checked \}\) \}/)
|
|
2853
|
+
assert.match(src, /placeholder: '补充指令(可选)——前置=让子任务具备开跑条件;后置=把这批活收口。具体动作由 hook agent 自行决策'/)
|
|
2854
|
+
// ③ state 展示四态 + 未启用 + 耗时(运行中=已跑、落定=净耗时)
|
|
2855
|
+
assert.match(src, /\{ idle: \{ label: off \? '未启用' : '待运行', color: C\.text2 \}, running: \{ label: '运行中', color: C\.brand \}, done: \{ label: '已完成', color: C\.ok \}, failed: \{ label: '失败', color: C\.err \} \}/)
|
|
2856
|
+
assert.match(src, /if \(st === 'running'\) dur = '已跑 ' \+ elapsedSince\(run\.at\)/)
|
|
2857
|
+
assert.match(src, /else if \(run\.endedAt\) dur = '耗时 ' \+ fmtDur\(/)
|
|
2858
|
+
// ④ 失败 → 「前往裁决」:同页锚点跳转(裁决区 id 固定,DetailView 已挂 id)
|
|
2859
|
+
assert.match(src, /m\.state === 'failed' \? React\.createElement\('button', \{ onClick: goArbitration/)
|
|
2860
|
+
assert.match(src, /'前往裁决'/)
|
|
2861
|
+
assert.match(src, /function goArbitration\(\) \{ var el = document\.getElementById\('tskb-escalation'\); if \(el && el\.scrollIntoView\) el\.scrollIntoView\(\{ block: 'center' \}\) \}/)
|
|
2862
|
+
assert.match(src, /React\.createElement\('div', \{ id: 'tskb-escalation'/)
|
|
2863
|
+
// ⑤ run 跳会话(照 verifierRun 跳转既有模式)+ post 的 run id 落在 verifierRun 位
|
|
2864
|
+
assert.match(src, /onClick: function \(\) \{ if \(uiWorkspaceSvc\) uiWorkspaceSvc\.openSession\(m\.run\.id\) \}/)
|
|
2865
|
+
assert.match(src, /var rid = runId \|\| \(phase === 'post' \? task\.verifierRun : null\)/)
|
|
2866
|
+
assert.match(src, /var role = 'hook-' \+ phase/)
|
|
2867
|
+
// ⑥ 保存通道:update-task RPC 的 hooks 字段(UI 不做权限判断,失败原因原样 ⚠️ 回显)
|
|
2868
|
+
assert.match(src, /rpc\('update-task', \{ taskId: task\.id, hooks: \{ pre: pre\.item, post: post\.item \} \}\)/)
|
|
2869
|
+
assert.match(src, /if \(r && r\.ok === false\) \{ setMsg\('⚠️ ' \+ \(r\.error \|\| '保存失败'\)\); return \}/)
|
|
2870
|
+
// ⓪ 防再发(2026-10-05 详情页崩板事故):组件首行必须完成 React hook 解构——bundler 只注入 React 本体,
|
|
2871
|
+
// 裸用 useState = 运行期 ReferenceError → shell 容错层卸载整个 shell.overlay(看板整体消失)
|
|
2872
|
+
assert.match(src, /function HooksSection\(props\) \{\s+var _R = React; var useState = _R\.useState/)
|
|
2873
|
+
// ⑦ 提交体三条口径:空 prompt=撤点位(null);state/runId/pending 原样透传(否则在跑的 hook 会被打回 idle);
|
|
2874
|
+
// 运行中的点位不清空(run 还在飞,撤声明会让结算落到空点位)——删除意图保留 + 回一行提示
|
|
2875
|
+
assert.match(src, /if \(!String\(b\.prompt \|\| ''\)\.trim\(\)\) \{/)
|
|
2876
|
+
assert.match(src, /if \(cur && cur\.state === 'running'\) return \{ item: keep\(cur\), warn: ph \+ ' 运行中:本次不清空该点位(run 还在跑),等它跑完再撤' \}/)
|
|
2877
|
+
assert.match(src, /return \{ item: null \}/)
|
|
2878
|
+
assert.match(src, /if \(cur\.state\) item\.state = cur\.state/)
|
|
2879
|
+
assert.match(src, /if \(cur\.runId\) item\.runId = cur\.runId/)
|
|
2880
|
+
assert.match(src, /if \(cur\.pending\) item\.pending = true/)
|
|
2881
|
+
assert.match(src, /setMsg\(warns\.length \? '⚠️ ' \+ warns\.join\(';'\) : '✅ 已保存 hooks 配置'\)/)
|
|
2882
|
+
// 组装产物里也要有
|
|
2883
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
2884
|
+
for (const s of ['Hooks(可选点位)', '前往裁决', '保存 hooks']) assert.ok(built.includes(s), '产物应含:' + s)
|
|
2885
|
+
})
|
|
2886
|
+
|
|
2887
|
+
test('README 双份同步记录 hooks UI 口径(相位徽章 / 详情编辑区 / 自行决策 + 歧义兜底)', () => {
|
|
2888
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
2889
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
2890
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
2891
|
+
for (const s of ['可选 hooks', '串行闸门', '自行决策', '歧义上报', '⏳ 前置准备中', '🧪 收尾中', '⚠️ hook 失败待裁决', 'hooks 仅主窗口可设']) {
|
|
2892
|
+
assert.ok(pkg.includes(s), 'README 应记录 hooks UI 口径:' + s)
|
|
2893
|
+
}
|
|
2894
|
+
})
|
|
2895
|
+
|
|
2896
|
+
// ===== 史诗拆分总开关(epicSplit,板级;口径:只关「引导」不禁「机制」)=====
|
|
2897
|
+
// false 时两个生效点:① Team 提示词第 6 条 TEAM_SPLIT_RULE 整条不注入(policy.splitRuleOf)
|
|
2898
|
+
// ② create-task RPC / task_create 工具返回体不再附 suggestSplit(policy.withSplitHint 第三参)。
|
|
2899
|
+
// 机制面(显式 parentId 建子卡 / 史诗自动收口 / hooks 状态机)一律不看这个开关。
|
|
2900
|
+
test('cfg/seed/normalizeBoard: epicSplit 缺省开(缺字段/脏值都算开),只有显式 false 才关', () => {
|
|
2901
|
+
assert.equal(core.cfg({}).epicSplit, true)
|
|
2902
|
+
assert.equal(core.cfg({ epicSplit: true }).epicSplit, true)
|
|
2903
|
+
assert.equal(core.cfg({ epicSplit: false }).epicSplit, false)
|
|
2904
|
+
assert.equal(core.cfg({ epicSplit: 'no' }).epicSplit, true) // 脏值不关(默认开)
|
|
2905
|
+
assert.equal(core.cfg({ epicSplit: 0 }).epicSplit, true)
|
|
2906
|
+
assert.equal(core.seed('s1').epicSplit, true)
|
|
2907
|
+
assert.equal(core.normalizeBoard({ tasks: [] }).epicSplit, true) // 老看板无字段 → 默认开(行为零变化)
|
|
2908
|
+
assert.equal(core.normalizeBoard({ tasks: [], epicSplit: false }).epicSplit, false) // 显式 false 原样保留
|
|
2909
|
+
assert.equal(core.normalizeBoard({ tasks: [], epicSplit: 'x' }).epicSplit, true) // 脏值收敛成默认开
|
|
2910
|
+
})
|
|
2911
|
+
|
|
2912
|
+
test('splitRuleOf: 第 6 条注入出口——缺省/true 与开关落地前逐字相同,显式 false 整条不出现', () => {
|
|
2913
|
+
assert.equal(splitRuleOf(undefined), '\n' + TEAM_SPLIT_RULE) // 老调用方/缺省 → 零变化
|
|
2914
|
+
assert.equal(splitRuleOf(true), '\n' + TEAM_SPLIT_RULE)
|
|
2915
|
+
assert.equal(splitRuleOf(false), '')
|
|
2916
|
+
assert.doesNotMatch(splitRuleOf(false), /大任务必须拆分|parentId|checkParentAuto/)
|
|
2917
|
+
// 拼接形态:第 5 条末尾直接接出口——关掉时编号 1~5 连续、不留空行尾巴
|
|
2918
|
+
assert.ok(('5. 尾部。' + splitRuleOf(true)).endsWith('\n' + TEAM_SPLIT_RULE))
|
|
2919
|
+
assert.equal('5. 尾部。' + splitRuleOf(false), '5. 尾部。')
|
|
2920
|
+
})
|
|
2921
|
+
|
|
2922
|
+
test('epicSplit 行为①:Team 提示词第 6 条随门禁注入/消失(真跑一次 section 组装,不是源码断言)', () => {
|
|
2923
|
+
const SID = FULL_SID
|
|
2924
|
+
const sections = []
|
|
2925
|
+
const ctx = {
|
|
2926
|
+
fs: {},
|
|
2927
|
+
effect: function () { return function () {} },
|
|
2928
|
+
get: function (name) {
|
|
2929
|
+
if (name !== 'systemPrompt') return null
|
|
2930
|
+
return { section: function (cfg) { sections.push(cfg); return function () {} }, context: function () { return function () {} } }
|
|
2931
|
+
},
|
|
2932
|
+
}
|
|
2933
|
+
// 只给 createDispatch 创建期真正用到的 deps 面;epicSplitOn 就是要验的门禁读取器
|
|
2934
|
+
const mkDeps = (on) => ({
|
|
2935
|
+
rt: async () => mkBoard([]), wt: async () => {}, mutateLocked: async (sid, fn) => fn(mkBoard([])), kickCycle: () => {},
|
|
2936
|
+
rootForSession: () => undefined, sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => ({}),
|
|
2937
|
+
feedbackOn: () => false, epicSplitOn: () => on,
|
|
2938
|
+
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
2939
|
+
})
|
|
2940
|
+
const mkState = () => { const s = { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} }; s.teamModeCache[SID] = true; return s }
|
|
2941
|
+
const textOf = (on) => {
|
|
2942
|
+
sections.length = 0
|
|
2943
|
+
createDispatch(ctx, mkState(), mkDeps(on))
|
|
2944
|
+
const sec = sections.find((s) => s.name === 'task-board:team-mode')
|
|
2945
|
+
assert.ok(sec, 'team-mode 引导段已注册')
|
|
2946
|
+
return sec.text({ agent: { id: SID } })
|
|
2947
|
+
}
|
|
2948
|
+
const on = textOf(true)
|
|
2949
|
+
assert.ok(on.includes(TEAM_SPLIT_RULE)); assert.match(on, /大任务必须拆分/); assert.match(on, /checkParentAuto/)
|
|
2950
|
+
const off = textOf(false)
|
|
2951
|
+
// 条款整条消失(连 parentId/checkParentAuto 都不再出现),基础 1~5 条引导一字不少
|
|
2952
|
+
assert.doesNotMatch(off, /大任务必须拆分|parentId=父卡 id|checkParentAuto|epic 父卡/)
|
|
2953
|
+
assert.match(off, /Team 模式已开启/); assert.match(off, /5\. Team 模式下 task_create 默认建为草稿/)
|
|
2954
|
+
assert.equal(on.replace(splitRuleOf(true), ''), off.replace(splitRuleOf(false), '')) // 除第 6 条外逐字相同
|
|
2955
|
+
})
|
|
2956
|
+
|
|
2957
|
+
test('withSplitHint: epicSplit=false 不附 suggestSplit(与未命中同形),缺省/true 零变化', () => {
|
|
2958
|
+
const big = mkTask({ title: '全量重写派发引擎' })
|
|
2959
|
+
const off = withSplitHint({ ok: true, task: {} }, big, false)
|
|
2960
|
+
assert.equal(off.suggestSplit, undefined)
|
|
2961
|
+
assert.deepEqual(Object.keys(off), ['ok', 'task']) // 返回体形态与「未命中」逐字同形
|
|
2962
|
+
assert.equal(withSplitHint({ ok: true, task: {} }, big, true).suggestSplit, SUGGEST_SPLIT_TEXT)
|
|
2963
|
+
assert.equal(withSplitHint({ ok: true, task: {} }, big).suggestSplit, SUGGEST_SPLIT_TEXT) // 两参老调用零变化
|
|
2964
|
+
assert.equal(withSplitHint({ ok: true, task: {} }, big, undefined).suggestSplit, SUGGEST_SPLIT_TEXT) // 缺省=开
|
|
2965
|
+
})
|
|
2966
|
+
|
|
2967
|
+
test('buildWorkerPrompt: Worker prompt 不含拆分条款(引导只在主窗口侧两处动态面,审计锁定)', () => {
|
|
2968
|
+
const t = mkTask({ title: '把整个引擎系统级重写', description: 'x'.repeat(600) })
|
|
2969
|
+
const p = core.buildWorkerPrompt(t, '', true)
|
|
2970
|
+
for (const s of ['建议粒度', '拆分', 'parentId', 'epic 卡', 'TASK_SIZE_CONTRACT', 'TEAM_SPLIT_RULE']) {
|
|
2971
|
+
assert.ok(!p.includes(s), 'Worker prompt 不该出现拆分引导:' + s)
|
|
2972
|
+
}
|
|
2973
|
+
// 源码级:buildWorkerPrompt 体内不引用拆分文案、也不引用门禁出口——Worker 侧没有可跳过的条款,
|
|
2974
|
+
// 所以 epicSplit 对 Worker prompt 是零影响(将来若往这里加拆分条款,必须同步接 epicSplit 门禁)
|
|
2975
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
2976
|
+
const body = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
|
|
2977
|
+
assert.ok(body.length > 500, 'buildWorkerPrompt 切片成功')
|
|
2978
|
+
assert.doesNotMatch(body, /TASK_SIZE_CONTRACT|TEAM_SPLIT_RULE|splitRuleOf|建议粒度/)
|
|
2979
|
+
})
|
|
2980
|
+
|
|
2981
|
+
test('epicSplit 透出:get-tasks 返回确定布尔值(缺省 true / 显式 false),set-board-config 白名单落盘', async () => {
|
|
2982
|
+
const board = mkBoard([])
|
|
2983
|
+
const h = mkRpcHandlers(board)
|
|
2984
|
+
assert.equal((await h['get-tasks']({})).epicSplit, true) // 老看板缺字段 → true(零变化)
|
|
2985
|
+
await h['set-board-config']({ key: 'epicSplit', value: false })
|
|
2986
|
+
assert.equal(board.epicSplit, false)
|
|
2987
|
+
assert.equal((await h['get-tasks']({})).epicSplit, false)
|
|
2988
|
+
await h['set-board-config']({ key: 'epicSplit', value: true })
|
|
2989
|
+
assert.equal((await h['get-tasks']({})).epicSplit, true)
|
|
2990
|
+
})
|
|
2991
|
+
|
|
2992
|
+
test('epicSplit 行为:关掉后 create-task 不附 suggestSplit,但显式 parentId 建子卡 + 史诗自动收口照常(机制不禁)', async () => {
|
|
2993
|
+
const board = mkBoard([])
|
|
2994
|
+
const h = mkRpcHandlers(board)
|
|
2995
|
+
const big = { title: '全量重构整个派发引擎', description: '把整个看板的派发与结算链路系统级重写一遍,覆盖全部管线与状态机' }
|
|
2996
|
+
const on = await h['create-task'](Object.assign({ id: 'epic-on' }, big))
|
|
2997
|
+
assert.equal(on.ok, true); assert.equal(on.suggestSplit, SUGGEST_SPLIT_TEXT) // 缺省开:引导照旧
|
|
2998
|
+
assert.equal((await h['set-board-config']({ key: 'epicSplit', value: false })).ok, true)
|
|
2999
|
+
const r1 = await h['create-task'](Object.assign({ id: 'epic-off' }, big))
|
|
3000
|
+
assert.equal(r1.ok, true); assert.equal('suggestSplit' in r1, false) // 引导关掉:不再劝拆
|
|
3001
|
+
// 机制不禁①:显式 parentId 建子卡照常
|
|
3002
|
+
const child = await h['create-task']({ id: 'kid-1', title: '拆出来的一小块', description: '明确描述', parentId: 'epic-off' })
|
|
3003
|
+
assert.equal(child.ok, true); assert.equal(child.task.parentId, 'epic-off')
|
|
3004
|
+
// 机制不禁②:子任务全部了结 → 父卡照常自动转 verifying(checkParentAuto 不看开关)
|
|
3005
|
+
const ep = board.tasks.find((x) => x.id === 'epic-off'); ep.status = 'in-progress'
|
|
3006
|
+
const kid = board.tasks.find((x) => x.id === 'kid-1'); kid.status = 'resolved'
|
|
3007
|
+
assert.equal(core.checkParentAuto(board, kid), ep)
|
|
3008
|
+
assert.equal(ep.status, 'verifying')
|
|
3009
|
+
})
|
|
3010
|
+
|
|
3011
|
+
test('epicSplit 接线(源码级):缓存同步 / Team 引导段门禁 / 双出口第三参 / get-tasks 透出 / UI「功能」小节', () => {
|
|
3012
|
+
const src = hostSrc()
|
|
3013
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8') // hostSrc 不含 core(纯逻辑核)
|
|
3014
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
3015
|
+
// 配置层(core.mjs:cfg 兜底 / seed 初值 / normalizeBoard 老看板补齐)
|
|
3016
|
+
assert.match(coreSrc, /epicSplit: d\.epicSplit !== false/)
|
|
3017
|
+
assert.match(coreSrc, /if \(typeof d\.epicSplit !== 'boolean'\) d\.epicSplit = true/)
|
|
3018
|
+
assert.match(coreSrc, /notifyDone: true, epicSplit: true, minWorkers: 1/) // seed 缺省开
|
|
3019
|
+
// 缓存链路:rt() 读盘同步 + set-board-config 当场回填(Team 提示词是同步组装,只能读缓存)
|
|
3020
|
+
assert.match(src, /epicSplitCache\[sid\] = nd\.epicSplit !== false/)
|
|
3021
|
+
assert.match(src, /function epicSplitOn\(sid\) \{ return epicSplitCache\[sid\] !== false \}/)
|
|
3022
|
+
assert.match(src, /epicSplitOn: session\.epicSplitOn/)
|
|
3023
|
+
assert.match(src, /var epicSplitOn = deps\.epicSplitOn/)
|
|
3024
|
+
assert.match(src, /else if \(args\.key === 'epicSplit'\) \{ d\.epicSplit = !!args\.value; epicSplitCache\[sid\] = d\.epicSplit \}/)
|
|
3025
|
+
// ① Team 提示词第 6 条经门禁出口注入
|
|
3026
|
+
assert.match(src, /splitRuleOf\(epicSplitOn\(String\(agent\.id\)\)\)/)
|
|
3027
|
+
// ② 双出口(RPC + 工具)都传第三参
|
|
3028
|
+
assert.equal((src.match(/withSplitHint\(\{ ok: true, task: t \}, t, cfg\(d\)\.epicSplit\)/g) || []).length, 2)
|
|
3029
|
+
assert.match(src, /d\.epicSplit = cfg\(d\)\.epicSplit/) // get-tasks 透出确定布尔值
|
|
3030
|
+
// ③ UI:入池配置弹层「功能」小节一行开关(勾选态缺字段=开)+ 状态透传链
|
|
3031
|
+
assert.match(cli, /'功能'/)
|
|
3032
|
+
assert.match(cli, /'🧩 史诗拆分:大任务引导拆为 epic \+ 子任务'/)
|
|
3033
|
+
assert.match(cli, /checked: props\.epicSplit !== false, onChange: function \(e\) \{ rpc\('set-board-config', \{ key: 'epicSplit', value: e\.target\.checked \}\)/)
|
|
3034
|
+
assert.match(cli, /关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作/)
|
|
3035
|
+
assert.match(cli, /state\.epicSplit = !\(d && d\.epicSplit === false\)/)
|
|
3036
|
+
assert.match(cli, /useState\(state\.epicSplit\)/)
|
|
3037
|
+
assert.match(cli, /epicSplit: esOn/)
|
|
3038
|
+
})
|
|
3039
|
+
|
|
3040
|
+
test('README 双份同步记录 epicSplit 开关口径(关引导不禁机制)', () => {
|
|
3041
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
3042
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
3043
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致
|
|
3044
|
+
for (const s of ['`epicSplit`', '「**功能**」小节', '只停**引导**', '**机制不禁**', 'parentId']) {
|
|
3045
|
+
assert.ok(pkg.includes(s), 'README 应记录 epicSplit 口径:' + s)
|
|
3046
|
+
}
|
|
1542
3047
|
})
|