dsh-agent-board 1.7.8 → 1.8.0
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 +19 -8
- package/index.mjs +1 -0
- package/lib/cleanup.mjs +256 -0
- package/lib/client/dashboard.js +139 -11
- package/lib/client/kernel.js +32 -7
- package/lib/client/task-detail.js +13 -7
- package/lib/client.js +184 -25
- package/lib/core.mjs +24 -4
- package/lib/dispatch.mjs +187 -5
- package/lib/rpc.mjs +173 -26
- package/lib/usage.mjs +13 -4
- package/package.json +1 -1
- package/test/cleanup.test.mjs +341 -0
- package/test/conditional-get-tasks.test.mjs +96 -0
- package/test/core.test.mjs +410 -7
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/integration.test.mjs +153 -0
- package/test/scoreboard-client.test.mjs +15 -9
- package/test/scoreboard.test.mjs +44 -4
- package/test/ui-zoom-client.test.mjs +76 -0
package/test/core.test.mjs
CHANGED
|
@@ -480,25 +480,54 @@ test('buildVerifierPrompt: 注入交付物 + 验收脚本 + messages', () => {
|
|
|
480
480
|
assert.match(p, /做完了/); assert.match(p, /npm test/); assert.match(p, /board_verdict/); assert.match(p, /方案B/)
|
|
481
481
|
})
|
|
482
482
|
|
|
483
|
-
test('buildWorkerPrompt: git
|
|
483
|
+
test('buildWorkerPrompt: git 纪律红线(禁还原命令 + 精确路径 add)注入,verifier prompt 不注入(源码断言)', () => {
|
|
484
484
|
const t = mkTask({ id: 'g1' })
|
|
485
485
|
const p = core.buildWorkerPrompt(t)
|
|
486
486
|
assert.match(p, /git 纪律红线/)
|
|
487
487
|
assert.match(p, /禁止 git checkout \/ git restore \/ git reset --hard \/ git clean 等还原命令/)
|
|
488
488
|
assert.match(p, /会冲掉并行 Worker 与你自己的未提交在途编辑/)
|
|
489
489
|
assert.match(p, /确实需要干净基线时用 board_report(kind=escalate)上报/)
|
|
490
|
+
// 精确路径 add 条款(task-mv73ylwe;n-mv6xntg1p6i5 实证:并行 Worker 共享暂存区,宽泛 add 吞并他卡在途编辑)
|
|
491
|
+
assert.match(p, /提交时只允许精确路径 git add <本卡 touches 声明的文件>/)
|
|
492
|
+
assert.match(p, /禁止 git add -A \/ git add \. \/ git add 目录/)
|
|
493
|
+
assert.match(p, /并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进你的 commit/)
|
|
490
494
|
// verifier prompt 不动
|
|
491
495
|
const pv = core.buildVerifierPrompt(t)
|
|
492
496
|
assert.doesNotMatch(pv, /git 纪律红线/)
|
|
493
497
|
assert.doesNotMatch(pv, /reset --hard/)
|
|
498
|
+
assert.doesNotMatch(pv, /精确路径 git add/)
|
|
499
|
+
assert.doesNotMatch(pv, /git add -A/)
|
|
494
500
|
// 源码级:条款只在 buildWorkerPrompt 体内(切片边界到 buildVerifierPrompt 之前),不在 buildVerifierPrompt 体内
|
|
495
501
|
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
496
502
|
const wBody = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
|
|
497
503
|
assert.ok(wBody.length > 500, 'buildWorkerPrompt 切片成功')
|
|
498
504
|
assert.match(wBody, /git 纪律红线/)
|
|
499
505
|
assert.match(wBody, /git reset --hard/)
|
|
506
|
+
assert.match(wBody, /提交时只允许精确路径 git add/)
|
|
507
|
+
assert.match(wBody, /禁止 git add -A/)
|
|
500
508
|
const vBody = coreSrc.slice(coreSrc.indexOf('export function buildVerifierPrompt'), coreSrc.indexOf('export function buildHookPrompt'))
|
|
501
509
|
assert.doesNotMatch(vBody, /git 纪律红线/)
|
|
510
|
+
assert.doesNotMatch(vBody, /精确路径 git add/)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
test('buildWorkerPrompt: 大文件交付纪律(write 主体+edit 续块)注入,verifier prompt 不注入(源码断言)', () => {
|
|
514
|
+
const t = mkTask({ id: 'lf1' })
|
|
515
|
+
const p = core.buildWorkerPrompt(t)
|
|
516
|
+
assert.match(p, /大文件交付纪律/)
|
|
517
|
+
assert.match(p, /单文件交付预估 >8KB(或 spec 含全量文案\/长代码)时,禁止单次 write 写入全量/)
|
|
518
|
+
assert.match(p, /先 write 落主体骨架,再用 edit 追加续块(每块 ≤4KB)逐块落地/)
|
|
519
|
+
// verifier prompt 不动(纪律只约束干活的 Worker)
|
|
520
|
+
const pv = core.buildVerifierPrompt(t)
|
|
521
|
+
assert.doesNotMatch(pv, /大文件交付纪律/)
|
|
522
|
+
assert.doesNotMatch(pv, /每块 ≤4KB/)
|
|
523
|
+
// 源码级:指引段写死在 buildWorkerPrompt 体内(切片边界到 buildVerifierPrompt 之前),不在 buildVerifierPrompt 体内
|
|
524
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
525
|
+
const wBody = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
|
|
526
|
+
assert.ok(wBody.length > 500, 'buildWorkerPrompt 切片成功')
|
|
527
|
+
assert.match(wBody, /大文件交付纪律/)
|
|
528
|
+
assert.match(wBody, /每块 ≤4KB/)
|
|
529
|
+
const vBody = coreSrc.slice(coreSrc.indexOf('export function buildVerifierPrompt'), coreSrc.indexOf('export function buildHookPrompt'))
|
|
530
|
+
assert.doesNotMatch(vBody, /大文件交付纪律/)
|
|
502
531
|
})
|
|
503
532
|
|
|
504
533
|
// ===== 预研上下文段(瘦身分离形态,task-muvjs392):笔记全文 + 文件清单,不含文件内容本体 =====
|
|
@@ -838,6 +867,29 @@ test('pickDispatch: frozen(裁决挂起冻结)不参与任何自动派发',
|
|
|
838
867
|
assert.deepEqual(r2.verifs.map(t => t.id).sort(), ['fv', 'nv'])
|
|
839
868
|
})
|
|
840
869
|
|
|
870
|
+
// ===== 容器卡结构性排除(epic 占 worker 槽根修,用户实证:maxWorkers=3 时一张 =====
|
|
871
|
+
// pipeline=full 的 epic 被派出占槽、已就绪任务空等——此前只靠 pipeline=direct 口头约定)=====
|
|
872
|
+
test('pickDispatch: 有子任务的容器卡(epic)结构性不进派发候选,与 pipeline 无关', () => {
|
|
873
|
+
const epi = mkTask({ id: 'epic', pipeline: 'full' }) // 手滑建 full 档 epic(缺省管线,未传 direct)
|
|
874
|
+
const c1 = mkTask({ id: 'c1', parentId: 'epic' })
|
|
875
|
+
const solo = mkTask({ id: 'solo' }) // 无子任务普通卡照常派发(防回归)
|
|
876
|
+
const r = core.pickDispatch(mkBoard([epi, c1, solo]), 5, 0, null)
|
|
877
|
+
assert.deepEqual(r.pendings.map(t => t.id).sort(), ['c1', 'solo']) // epic 被排除,子任务与普卡照派
|
|
878
|
+
// 子任务口径含已归档(与 childStats 同源:parentId 仍指向即算容器)——全归档的 epic 也不复活进池
|
|
879
|
+
const e2 = mkTask({ id: 'e2', pipeline: 'full' })
|
|
880
|
+
const k1 = mkTask({ id: 'k1', parentId: 'e2', status: 'archived' })
|
|
881
|
+
assert.deepEqual(core.pickDispatch(mkBoard([e2, k1]), 5, 0, null).pendings.map(t => t.id), [])
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
test('pickDispatch: 容器卡排除只挡 Worker 派发——epic 收口转 verifying 后 Verifier 派发不受影响', () => {
|
|
885
|
+
// 既有正确行为:子任务全了结后 epic 转 verifying,派 Verifier 验收是真跑(verifs 分支不动)
|
|
886
|
+
const epi = mkTask({ id: 'epic', status: 'verifying', pipeline: 'full' })
|
|
887
|
+
const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
888
|
+
const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'archived' })
|
|
889
|
+
const r = core.pickDispatch(mkBoard([epi, c1, c2]), 0, 5, null)
|
|
890
|
+
assert.deepEqual(r.verifs.map(t => t.id), ['epic'])
|
|
891
|
+
})
|
|
892
|
+
|
|
841
893
|
// ===== touches 文件级排他:glob 最小匹配器 =====
|
|
842
894
|
test('patOverlap: 完全相同路径冲突 + 归一化(\\ → /、去 ./ 前缀与尾 /)', () => {
|
|
843
895
|
assert.equal(core.patOverlap('src/a.js', 'src/a.js'), true)
|
|
@@ -2101,9 +2153,10 @@ test('Token 区统计范围接线:get-tasks 接 range 透传 + fetchTasks 带
|
|
|
2101
2153
|
// host:get-tasks 把 args.range 透传给聚合(过滤在 host 做——run 级数据只在 host)
|
|
2102
2154
|
assert.match(host, /aggregateUsageSummary\(d\.tasks, args && args\.range\)/)
|
|
2103
2155
|
assert.match(host, /runs\[i\]\.usage/) // run 级留账(范围过滤的数据源,先于本次改动已在)
|
|
2104
|
-
// client
|
|
2156
|
+
// client:轮询带上当前统计范围;空范围传 {} 占位(rpc 助手 args||{} 归一,host 只读 range 字段,
|
|
2157
|
+
// 与老 host 调用形态等价;task-mv492for 起 {} 上再挂 ifHash 做条件请求)
|
|
2105
2158
|
assert.match(cli, /var rgSend = activeRange\(\)/)
|
|
2106
|
-
assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} :
|
|
2159
|
+
assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : \{\}/)
|
|
2107
2160
|
assert.match(cli, /rpc\('get-tasks', rpcArgs\)/)
|
|
2108
2161
|
// 范围守卫:响应回来时范围已变则丢弃旧范围的聚合,不覆盖新范围的值
|
|
2109
2162
|
assert.match(cli, /if \(rgKey === \(rgNow\.from \+ '~' \+ rgNow\.to\)\) state\.usageSummary = \(d && d\.usageSummary\) \|\| null/)
|
|
@@ -2747,6 +2800,7 @@ test('README 双份:运行区子会话 id 归属标注 + git 纪律红线各
|
|
|
2747
2800
|
assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
|
|
2748
2801
|
assert.ok(r1.indexOf('对应子代理列表同名条目') >= 0, 'README 缺运行区子会话 id 归属标注说明')
|
|
2749
2802
|
assert.ok(r1.indexOf('git 纪律红线') >= 0, 'README 缺 git 纪律红线说明')
|
|
2803
|
+
assert.ok(r1.indexOf('git add -A') >= 0, 'README 缺精确路径 add 条款说明')
|
|
2750
2804
|
})
|
|
2751
2805
|
|
|
2752
2806
|
// ===== 归档 tab 数据源口径(反馈 n-muyg3e0m9z7i):归档 tab 空 vs 仪表盘 68 根修 =====
|
|
@@ -4226,6 +4280,49 @@ test('task_cancel GUI 按钮门禁:canCancel 三态 + 详情页按钮接线 +
|
|
|
4226
4280
|
}
|
|
4227
4281
|
})
|
|
4228
4282
|
|
|
4283
|
+
// ===== 详情面板「取消」消歧(task-mv493xjs,巡检实证 n-mv2turadjv63 误触 task_cancel)=====
|
|
4284
|
+
test('详情面板消歧:「取消」改名「取消任务」+ 危险操作区视觉分层 + ✕ 关闭钮(源码+产物双锁)', () => {
|
|
4285
|
+
const tdsrc = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
|
|
4286
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
4287
|
+
// ① 新文案源码锁定:按钮标签为「取消任务」,不再出现裸「取消」按钮标签(ic('ban', 10), '取消')
|
|
4288
|
+
assert.ok(tdsrc.indexOf("ic('ban', 10), '取消任务'") >= 0, 'task-detail.js 缺「取消任务」新文案')
|
|
4289
|
+
assert.ok(tdsrc.indexOf("ic('ban', 10), '取消')") < 0, 'task-detail.js 仍残留裸「取消」按钮标签')
|
|
4290
|
+
// ② 视觉分层:破坏性按钮独立成区(虚线分隔 + 「危险操作」前缀 + aria-label),与表单按钮组拉开
|
|
4291
|
+
assert.ok(tdsrc.indexOf("'aria-label': '危险操作'") >= 0, 'task-detail.js 缺危险操作区分组')
|
|
4292
|
+
assert.ok(tdsrc.indexOf("borderTop: '1px dashed ' + C.err") >= 0, 'task-detail.js 缺危险区顶部分隔线')
|
|
4293
|
+
assert.ok(tdsrc.indexOf('危险操作') >= 0, 'task-detail.js 缺「危险操作」前缀标签')
|
|
4294
|
+
// ④ ✕ 关闭钮:详情头部右上显性关闭(与「← 返回看板」同行走 state.detailId = null)
|
|
4295
|
+
assert.ok(tdsrc.indexOf("title: '关闭详情面板(返回看板)'") >= 0, 'task-detail.js 缺 ✕ 关闭钮')
|
|
4296
|
+
// ③ 产物重组装:新文案/分层/关闭钮全部进 lib/client.js
|
|
4297
|
+
for (const m of ["ic('ban', 10), '取消任务'", '危险操作', "title: '关闭详情面板(返回看板)'"]) {
|
|
4298
|
+
assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m)
|
|
4299
|
+
}
|
|
4300
|
+
})
|
|
4301
|
+
|
|
4302
|
+
// ===== GUI RPC 操作者回退(task-mv493xjs,巡检次生观察①:流转轨迹 actor 显示 unknown)=====
|
|
4303
|
+
// 面板 HTTP RPC 无 agent 调用栈 → getActorId() 落 'unknown';rpcActor 回退 args 自带 sessionId。
|
|
4304
|
+
test('GUI RPC actor 回退:getActorId=unknown 时流转历史 actor 落会话身份(不再 unknown)', async () => {
|
|
4305
|
+
const t = mkTask({ id: 'g1', status: 'pending' })
|
|
4306
|
+
const board = mkBoard([t])
|
|
4307
|
+
const h = mkRpcHandlers(board, { getActorId: () => 'unknown' })
|
|
4308
|
+
const r = await h['cancel-task']({ taskId: 'g1', reason: 'GUI 误建' })
|
|
4309
|
+
assert.equal(r.ok, true)
|
|
4310
|
+
// actor 不再是 unknown,而是 rpcSessionId(args) 归一出的会话 id(测试桩=FULL_SID)
|
|
4311
|
+
assert.ok(t.history.some(function (x) { return x.to === 'cancelled' && x.actor === FULL_SID }), 'history actor 未回退到会话 id')
|
|
4312
|
+
assert.ok(!t.history.some(function (x) { return x.actor === 'unknown' }), 'history 仍出现 unknown actor')
|
|
4313
|
+
// 正对照:有真实 initiator 时不回退(保持 actor 原值)
|
|
4314
|
+
const t2 = mkTask({ id: 'g2', status: 'pending' })
|
|
4315
|
+
const h2 = mkRpcHandlers(mkBoard([t2]))
|
|
4316
|
+
await h2['cancel-task']({ taskId: 'g2' })
|
|
4317
|
+
assert.ok(t2.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' }), '有 initiator 时 actor 被意外改写')
|
|
4318
|
+
// 归档通道同口径(GUI 高频操作)
|
|
4319
|
+
const t3 = mkTask({ id: 'g3', status: 'resolved' })
|
|
4320
|
+
const h3 = mkRpcHandlers(mkBoard([t3]), { getActorId: () => 'unknown' })
|
|
4321
|
+
const r3 = await h3['archive-task']({ taskId: 'g3' })
|
|
4322
|
+
assert.equal(r3.ok, true)
|
|
4323
|
+
assert.ok(t3.history.some(function (x) { return x.to === 'archived' && x.actor === FULL_SID }), '归档 history actor 未回退到会话 id')
|
|
4324
|
+
})
|
|
4325
|
+
|
|
4229
4326
|
// ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
|
|
4230
4327
|
test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
|
|
4231
4328
|
// RPC 通道:归档末子卡 → 父卡 in-progress → verifying
|
|
@@ -4304,9 +4401,10 @@ test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 t
|
|
|
4304
4401
|
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
4305
4402
|
assert.equal((rpcSrc.match(/archiveErr\(sid, t\)/g) || []).length, 3)
|
|
4306
4403
|
// kernel:hash 短路分支存在(hash 相同跳过 tasks 赋值 + notify;老 host 无 hash 恒视为变化)
|
|
4404
|
+
// task-mv492for:tasksChanged 叠加 unchanged 闸门(host 条件响应不含 tasks 时绝不清空列表)
|
|
4307
4405
|
const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
|
|
4308
4406
|
assert.match(ksrc, /var newHash = \(d && d\.tasksHash\) \|\| ''/)
|
|
4309
|
-
assert.match(ksrc, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
|
|
4407
|
+
assert.match(ksrc, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
|
|
4310
4408
|
assert.match(ksrc, /if \(tasksChanged\) \{[\s\S]*?notify\(\)/)
|
|
4311
4409
|
})
|
|
4312
4410
|
|
|
@@ -4488,13 +4586,37 @@ test('isRoot 持久语义:host 侧改读 parentSession(根=无父),防
|
|
|
4488
4586
|
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
4489
4587
|
// isRoot 持久判定入口:根会话=无父,不再读活跃 roots() 集
|
|
4490
4588
|
assert.match(rpcSrc, /async function isRootPersistent\(sid\)/)
|
|
4491
|
-
|
|
4589
|
+
// task-mv59nn9m:判据改用 delegationDepth——无父=根,或 parentSession 有值但 delegationDepth===0(fork/seed 会话)也认根
|
|
4590
|
+
assert.match(rpcSrc, /return !hh\.parentSession \|\| \(typeof hh\.delegationDepth === 'number' && hh\.delegationDepth === 0\)/)
|
|
4492
4591
|
assert.match(rpcSrc, /d\.isRoot = await isRootPersistent\(sid\)/)
|
|
4493
4592
|
// 指针注释:防抖仍在客户端 kernel.js applyIsRoot,曾确认 true 需连续 3 次 false 才收
|
|
4494
4593
|
assert.match(rpcSrc, /防抖仍在客户端:kernel\.js applyIsRoot/)
|
|
4495
4594
|
assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
|
|
4496
4595
|
})
|
|
4497
4596
|
|
|
4597
|
+
// ===== fork 会话认根(task-mv59nn9m,用户报障:ontology_ai「本体数据平台开发」fork 会话看板不可见)=====
|
|
4598
|
+
// 根因:isRootPersistent stat 主源旧判据 `!header.parentSession` 把 fork/seed 会话
|
|
4599
|
+
//(v3→v4 迁移产物:parentSession 有值 + delegationDepth:0 + isSeeded:true)误判非根 → isRoot:false → 看板入口消失。
|
|
4600
|
+
// 修法:判据改 `!h.parentSession || (typeof h.delegationDepth==='number' && h.delegationDepth===0)`;
|
|
4601
|
+
// delegationDepth 缺省(v3 时代老会话无此字段)且有 parentSession 时维持非根保守判定(兼容 v3 时代子代理)。
|
|
4602
|
+
test('isRootPersistent 判据 delegationDepth(行为级):fork 认根 / 子代理非根 / 老形态保守 / 真根不变', async () => {
|
|
4603
|
+
function mkPersistCtx(header) {
|
|
4604
|
+
return { get: function (name) { if (name === 'sessionPersistence') return { stat: async function () { return { header: header } } }; return undefined } }
|
|
4605
|
+
}
|
|
4606
|
+
// ① fork/seed 头(parentSession 有 + delegationDepth 0)→ 根(本卡修复目标)
|
|
4607
|
+
const h1 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 0 }))
|
|
4608
|
+
assert.equal((await h1['get-tasks']({})).isRoot, true)
|
|
4609
|
+
// ② 真子代理头(parentSession 有 + delegationDepth 1)→ 非根
|
|
4610
|
+
const h2 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 1 }))
|
|
4611
|
+
assert.equal((await h2['get-tasks']({})).isRoot, false)
|
|
4612
|
+
// ③ 老形态头(parentSession 有 + delegationDepth 缺省,v3 时代会话)→ 非根(保守判定不放行)
|
|
4613
|
+
const h3 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent' }))
|
|
4614
|
+
assert.equal((await h3['get-tasks']({})).isRoot, false)
|
|
4615
|
+
// ④ 真根(无 parentSession)→ 根
|
|
4616
|
+
const h4 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({}))
|
|
4617
|
+
assert.equal((await h4['get-tasks']({})).isRoot, true)
|
|
4618
|
+
})
|
|
4619
|
+
|
|
4498
4620
|
// ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
|
|
4499
4621
|
// 依赖卡 task-muuw4ov7 落地的真实字段契约(本文件上方 hooks 测试段即其口径):
|
|
4500
4622
|
// epic.hooks = { pre: { enabled, prompt, state, runId, pending? }, post: 同构 };state ∈ idle|running|done|failed;
|
|
@@ -5092,6 +5214,82 @@ test('卡3②③:重启 reconcile——存活的续跑 Worker 被找回重挂
|
|
|
5092
5214
|
assert.equal(h.runs['k1'], before)
|
|
5093
5215
|
})
|
|
5094
5216
|
|
|
5217
|
+
// ===== 僵尸 run 留档清扫(task-mv22z61v):落定卡 running 无 endedAt 留档关账 + 清卡面认领位 =====
|
|
5218
|
+
test('僵尸 run 留档清扫:落定卡 running 无 endedAt 留档被关账+清位;在飞卡/活跃 rec 不扫;二次运行零改动', async () => {
|
|
5219
|
+
const nowIso = new Date().toISOString()
|
|
5220
|
+
// ① 僵尸 verifier 留档:resolved 卡 + verifierRun 残留指向死会话 + runs[] 里 verifier:running 无 endedAt
|
|
5221
|
+
const zv = mkTask({ id: 'zv', title: '僵尸 verifier 卡', status: 'resolved', verifierRun: 'v-dead', verifierRunAt: '2026-01-01T00:00:00Z',
|
|
5222
|
+
runs: [{ role: 'verifier', id: 'v-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
|
|
5223
|
+
// ② 僵尸 worker 留档:cancelled 卡 + claimedBy 残留指向死会话
|
|
5224
|
+
const zw = mkTask({ id: 'zw', title: '僵尸 worker 卡', status: 'cancelled', claimedBy: 'w-dead', claimedAt: '2026-01-01T00:00:00Z',
|
|
5225
|
+
runs: [{ role: 'worker', id: 'w-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
|
|
5226
|
+
// ③ 已关账留档:outcome 已落定 + endedAt,非 running 条目不该被碰(不误扫非 running 留档)
|
|
5227
|
+
const zc = mkTask({ id: 'zc', title: '已关账卡', status: 'resolved',
|
|
5228
|
+
runs: [{ role: 'worker', id: 'w-ok', at: '2026-01-01T00:00:00Z', model: '', outcome: 'completed', endedAt: '2026-01-01T00:05:00Z' }] })
|
|
5229
|
+
// ④ 在飞卡(in-progress):running 留档是 reconcile/结算通道辖区,绝不误扫(断言②)
|
|
5230
|
+
const inflight = mkTask({ id: 'fly', title: '在飞卡', status: 'in-progress', claimedBy: 'w-alive', claimedAt: nowIso,
|
|
5231
|
+
runs: [{ role: 'worker', id: 'w-alive', at: nowIso, model: '', outcome: 'running', continuable: true }] })
|
|
5232
|
+
// ⑤ 在飞卡(verifying)+ 活跃 rec:双重护栏——status 非落定 + 内存活跃表里还活着,都该跳过(断言② + 无活跃 rec 才扫)
|
|
5233
|
+
const vfy = mkTask({ id: 'vfy', title: '验收中卡', status: 'verifying', verifierRun: 'v-alive', verifierRunAt: nowIso,
|
|
5234
|
+
runs: [{ role: 'verifier', id: 'v-alive', at: nowIso, model: '', outcome: 'running' }] })
|
|
5235
|
+
const board = mkBoard([zv, zw, zc, inflight, vfy])
|
|
5236
|
+
const h = mkUsageDispatch(board)
|
|
5237
|
+
h.runs['vfy'] = { id: 'v-alive', role: 'verifier', taskId: 'vfy', startedAt: Date.now(), model: '', settled: false }
|
|
5238
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
5239
|
+
// ① 僵尸 verifier 留档:关账(stale-closed + endedAt + note)+ 清 verifierRun 位 + 留一行 history
|
|
5240
|
+
assert.equal(zv.runs[0].outcome, 'stale-closed')
|
|
5241
|
+
assert.ok(zv.runs[0].endedAt, '僵尸 verifier 留档要带 endedAt')
|
|
5242
|
+
assert.equal(zv.runs[0].note, 'host 重启僵尸留档清扫')
|
|
5243
|
+
assert.equal(zv.verifierRun, null)
|
|
5244
|
+
assert.equal('verifierRunAt' in zv, false)
|
|
5245
|
+
assert.equal(zv.history.filter(function (x) { return /僵尸 run 留档已关账/.test(x.note || '') }).length, 1)
|
|
5246
|
+
// ② 僵尸 worker 留档:关账 + 清 claimedBy 位
|
|
5247
|
+
assert.equal(zw.runs[0].outcome, 'stale-closed')
|
|
5248
|
+
assert.ok(zw.runs[0].endedAt)
|
|
5249
|
+
assert.equal(zw.runs[0].note, 'host 重启僵尸留档清扫')
|
|
5250
|
+
assert.equal(zw.claimedBy, null)
|
|
5251
|
+
assert.equal(zw.claimedAt, null)
|
|
5252
|
+
// ③ 已关账留档零改动
|
|
5253
|
+
assert.equal(zc.runs[0].outcome, 'completed')
|
|
5254
|
+
assert.equal(zc.runs[0].endedAt, '2026-01-01T00:05:00Z')
|
|
5255
|
+
assert.equal(zc.runs[0].note, undefined)
|
|
5256
|
+
// ④⑤ 在飞卡 running 留档零改动(断言②:in-progress/verifying 都不扫)
|
|
5257
|
+
assert.equal(inflight.runs[0].outcome, 'running')
|
|
5258
|
+
assert.equal(inflight.runs[0].endedAt, undefined)
|
|
5259
|
+
assert.equal(inflight.claimedBy, 'w-alive')
|
|
5260
|
+
assert.equal(vfy.runs[0].outcome, 'running')
|
|
5261
|
+
assert.equal(vfy.runs[0].endedAt, undefined)
|
|
5262
|
+
assert.equal(vfy.verifierRun, 'v-alive')
|
|
5263
|
+
// ⑤ 幂等:二次运行零改动(关账后 outcome!=running,不再新增 history 行 / 不再刷新 endedAt)
|
|
5264
|
+
const zvEndedAt = zv.runs[0].endedAt
|
|
5265
|
+
const zwEndedAt = zw.runs[0].endedAt
|
|
5266
|
+
const zvHistoryLen = zv.history.length
|
|
5267
|
+
const zwHistoryLen = zw.history.length
|
|
5268
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
5269
|
+
assert.equal(zv.runs[0].outcome, 'stale-closed')
|
|
5270
|
+
assert.equal(zv.runs[0].endedAt, zvEndedAt)
|
|
5271
|
+
assert.equal(zv.history.length, zvHistoryLen)
|
|
5272
|
+
assert.equal(zw.runs[0].outcome, 'stale-closed')
|
|
5273
|
+
assert.equal(zw.runs[0].endedAt, zwEndedAt)
|
|
5274
|
+
assert.equal(zw.history.length, zwHistoryLen)
|
|
5275
|
+
})
|
|
5276
|
+
|
|
5277
|
+
test('僵尸 run 留档清扫接线(源码级):sweepZombieRuns 定义 + poolCycle 挂点 + 只清扫落定卡门禁', () => {
|
|
5278
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
5279
|
+
// ① 清扫函数定义(含「只对落定卡做」状态门禁与「无活跃 rec 才扫」护栏)
|
|
5280
|
+
assert.match(dsp, /async function sweepZombieRuns\(sid, snap\) \{/)
|
|
5281
|
+
assert.match(dsp, /if \(t\.status !== 'resolved' && t\.status !== 'cancelled' && t\.status !== 'archived'\) return/)
|
|
5282
|
+
assert.match(dsp, /if \(rec && !rec\.settled && String\(rec\.id\) === rid\) return/)
|
|
5283
|
+
assert.match(dsp, /r\.outcome = 'stale-closed'/)
|
|
5284
|
+
assert.match(dsp, /r\.endedAt = new Date\(\)\.toISOString\(\)/)
|
|
5285
|
+
assert.match(dsp, /r\.note = 'host 重启僵尸留档清扫'/)
|
|
5286
|
+
assert.match(dsp, /if \(t\.verifierRun === rid\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
|
|
5287
|
+
assert.match(dsp, /if \(t\.claimedBy === rid\) \{ t\.claimedBy = null; t\.claimedAt = null \}/)
|
|
5288
|
+
assert.match(dsp, /return any \? d : null/) // 无改动不写盘(安静板不因清扫产生心跳写盘)
|
|
5289
|
+
// ② poolCycle 挂点:reconcile 之后、幽灵回收之前(落定卡僵尸留档 reconcile/reap 都管不到,须在空闲快进前兜到)
|
|
5290
|
+
assert.match(dsp, /try \{ await sweepZombieRuns\(sid, snap\) \} catch \(e\) \{ console\.error\('\[task-board\] 僵尸留档清扫失败:/)
|
|
5291
|
+
})
|
|
5292
|
+
|
|
5095
5293
|
test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次性 + 详情页 ↻ 续跑标注 + 文档收尾', () => {
|
|
5096
5294
|
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
5097
5295
|
const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
|
|
@@ -5250,8 +5448,9 @@ test('fetchTasks 设置开关权威纠偏:hash 不变时配置变化补 notify
|
|
|
5250
5448
|
const iNotify = src.indexOf('if (cfgDelta || usageDelta) notify()')
|
|
5251
5449
|
const iEsc = src.indexOf('if (tasksChanged) {\n var newEsc')
|
|
5252
5450
|
assert.ok(iDelta < iNotify && iEsc > iNotify, '补 notify 必须在配置赋值之后、escalation 分支之前')
|
|
5253
|
-
// ④ 既有渲染节约(tasksHash
|
|
5254
|
-
|
|
5451
|
+
// ④ 既有渲染节约(tasksHash 短路)结构保持——task-mv492for 起 tasksChanged 叠加 unchanged 闸门
|
|
5452
|
+
// (host 条件响应 unchanged:true 不含 tasks 全量时保留 state.tasks),hash 比对短路语义不变
|
|
5453
|
+
assert.match(src, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
|
|
5255
5454
|
assert.match(src, /if \(tasksChanged\) \{\s*\n\s*state\.tasksHash = newHash\s*\n\s*state\.tasks = \(d && d\.tasks\) \|\| \[\]\s*\n\s*\}/)
|
|
5256
5455
|
// ⑤ 产物接线:client.js 为拼装产物,真源改动必须已重建(产物与源一致由 build-client --check 兜底)
|
|
5257
5456
|
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
@@ -5551,6 +5750,7 @@ test('高优干预①(continuable):rec 无 run 也走宿主投递——hos
|
|
|
5551
5750
|
assert.equal(r.ok, true)
|
|
5552
5751
|
assert.equal(r.delivered, true, 'continuable rec 干预必须真的送达(旧实现整段被跳过 → delivered 恒 false)')
|
|
5553
5752
|
assert.equal(r.channel, 'steer', 'e2e 断言口径不变:channel 必须仍是 steer')
|
|
5753
|
+
assert.equal(r.state, 'delivered', '实时送达:state=delivered(task-mv6c7cbo 返回体新字段)')
|
|
5554
5754
|
assert.equal(hostCalls.length, 1)
|
|
5555
5755
|
assert.deepEqual(sent, [], 'host-protocol 可用时不再重复走 sendMessage(一次干预只投一次)')
|
|
5556
5756
|
assert.equal(hostCalls[0].p, parent) // 授权者 = 活的直接父 Agent
|
|
@@ -5597,6 +5797,7 @@ test('高优干预②(一次性 rec 逐字不变):agent.steer 优先、ste
|
|
|
5597
5797
|
board.tasks.push(idle)
|
|
5598
5798
|
r = await handlers['intervene-agent']({ taskId: 'iv8', message: '无 run 的干预' })
|
|
5599
5799
|
assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
5800
|
+
assert.equal(r.state, 'no-run', '真无 run:state=no-run(GUI 提示色区分字段,task-mv6c7cbo)')
|
|
5600
5801
|
assert.match(idle.history[idle.history.length - 1].note, /^高优干预: 无 run 的干预(无活跃 run,随下次派发注入)$/)
|
|
5601
5802
|
})
|
|
5602
5803
|
|
|
@@ -5656,6 +5857,57 @@ test('高优干预③(continuable 回退链):host 符号不可用 → send
|
|
|
5656
5857
|
assert.equal(t6.messages.filter((m) => m.kind === 'intervention').length, 1)
|
|
5657
5858
|
})
|
|
5658
5859
|
|
|
5860
|
+
// ===== 送达提示两态细分(task-mv6c7cbo,实证 n-mv65bhk0mg5w:run 已结算 rec 摘除、claimedBy 仍在续跑窗口,
|
|
5861
|
+
// 旧文案「无活跃 run」技术为真但对用户误导——干预实际随续跑注入)=====
|
|
5862
|
+
test('高优干预⑤(续跑窗口细分):无 rec 但续跑资格/认领位还在 → 文案「随续跑注入」+ state=resume-pending', async () => {
|
|
5863
|
+
// 形态 A:run 刚结算留档 incomplete+continuable(resumeTarget 命中),claimedBy 仍指已结算子会话(续跑窗口原形)
|
|
5864
|
+
const resumeTask = mkTask({
|
|
5865
|
+
id: 'iv9', title: '续跑窗口卡', status: 'in-progress', claimedBy: 'child-done',
|
|
5866
|
+
runs: [{ role: 'worker', id: 'child-done', at: '2026-01-01T00:00:00Z', model: '', outcome: 'incomplete', continuable: true }],
|
|
5867
|
+
})
|
|
5868
|
+
// 形态 B:认领位还在但无任何 runs 留档(resumeTarget 空,纯 claimedBy 重排窗口)
|
|
5869
|
+
const claimed = mkTask({ id: 'iv10', title: '重排窗口卡', status: 'in-progress', claimedBy: 'child-x' })
|
|
5870
|
+
const board = mkBoard([resumeTask, claimed])
|
|
5871
|
+
// resumeTarget 走真实函数(与 index.mjs 生产接线同径:从 createDispatch 返回体取出注入 deps)
|
|
5872
|
+
const dsp = mkContinuableDispatch(mkBoard([]))
|
|
5873
|
+
const handlers = mkRpcHandlers(board, { runsFor: () => ({}), resumeTarget: dsp.dispatch.resumeTarget })
|
|
5874
|
+
// A:resumeTarget 命中 → 续跑窗口文案 + state,且不得再出现失真原文「无活跃 run」
|
|
5875
|
+
let r = await handlers['intervene-agent']({ taskId: 'iv9', message: '窗口期干预A' })
|
|
5876
|
+
assert.equal(r.ok, true); assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
5877
|
+
assert.equal(r.state, 'resume-pending')
|
|
5878
|
+
const noteA = resumeTask.history[resumeTask.history.length - 1].note
|
|
5879
|
+
assert.match(noteA, /^高优干预: 窗口期干预A(run 刚结算\/续跑窗口,干预将随续跑注入)$/)
|
|
5880
|
+
assert.ok(!/无活跃 run/.test(noteA), '续跑窗口不得再报「无活跃 run」(task-mv6c7cbo 修复的失真原文): ' + noteA)
|
|
5881
|
+
// 干预原文落 messages——续跑指令经 buildMessages 原文带上(「随续跑注入」语义成立的依据)
|
|
5882
|
+
assert.equal(resumeTask.messages.filter((m) => m.kind === 'intervention').length, 1)
|
|
5883
|
+
assert.equal(resumeTask.messages.filter((m) => m.kind === 'intervention')[0].text, '窗口期干预A')
|
|
5884
|
+
// B:claimedBy 窗口(resumeTarget 空)→ 同一文案与 state
|
|
5885
|
+
r = await handlers['intervene-agent']({ taskId: 'iv10', message: '窗口期干预B' })
|
|
5886
|
+
assert.equal(r.delivered, false); assert.equal(r.state, 'resume-pending')
|
|
5887
|
+
assert.match(claimed.history[claimed.history.length - 1].note, /(run 刚结算\/续跑窗口,干预将随续跑注入)$/)
|
|
5888
|
+
// C:deps 未注入 resumeTarget(老宿主/测试桩降级)→ claimedBy 窗口仍命中,语义只降不炸
|
|
5889
|
+
const handlers2 = mkRpcHandlers(board, { runsFor: () => ({}) })
|
|
5890
|
+
r = await handlers2['intervene-agent']({ taskId: 'iv10', message: '窗口期干预C' })
|
|
5891
|
+
assert.equal(r.state, 'resume-pending')
|
|
5892
|
+
assert.match(claimed.history[claimed.history.length - 1].note, /干预将随续跑注入/)
|
|
5893
|
+
})
|
|
5894
|
+
|
|
5895
|
+
// ===== 两态细分接线(源码级):文案/state 字段/透出/接线不被将来改动悄悄抹掉 =====
|
|
5896
|
+
test('高优干预⑥(源码级):续跑窗口两态文案 + state 字段 + resumeTarget 透出与 rpc 接线在位', () => {
|
|
5897
|
+
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
5898
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
5899
|
+
const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
|
|
5900
|
+
assert.match(rpcSrc, /(run 刚结算\/续跑窗口,干预将随续跑注入)/) // 续跑窗口文案
|
|
5901
|
+
assert.match(rpcSrc, /(无活跃 run,随下次派发注入)/) // 真无 run 文案不回归
|
|
5902
|
+
assert.match(rpcSrc, /state = 'resume-pending'/)
|
|
5903
|
+
assert.match(rpcSrc, /state = 'no-run'/)
|
|
5904
|
+
assert.match(rpcSrc, /return \{ ok: true, delivered: delivered, channel: channel, state: state \}/) // 返回体带 state
|
|
5905
|
+
assert.match(rpcSrc, /resumeTarget\(t\) !== '' \|\| t\.claimedBy/) // 两态判据(锁内新鲜快照)
|
|
5906
|
+
assert.match(rpcSrc, /var resumeTarget = typeof deps\.resumeTarget === 'function'/) // 缺 dep 降级不炸
|
|
5907
|
+
assert.match(dsp, /resumeTarget: resumeTarget/) // dispatch 返回体透出
|
|
5908
|
+
assert.match(idx, /resumeTarget: dispatch\.resumeTarget/) // 生产接线
|
|
5909
|
+
})
|
|
5910
|
+
|
|
5659
5911
|
// ===== Bug B ④(源码级):投递点接线不被将来改动悄悄抹掉 =====
|
|
5660
5912
|
test('高优干预④(源码级):continuable 投递点 = host-protocol 符号键 + sendMessage 回退,且分路在 rec.continuable 上', () => {
|
|
5661
5913
|
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
@@ -5769,3 +6021,154 @@ test('巡检三连②-③:归档「恢复待办」confirm 门禁(list 不弹
|
|
|
5769
6021
|
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
5770
6022
|
assert.match(built, /window\.confirm\('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token。继续/)
|
|
5771
6023
|
})
|
|
6024
|
+
|
|
6025
|
+
// ===== set-board-config 模型字段校验(task-mv73ym2k,实证 n-mv6ezj6vary4)=====
|
|
6026
|
+
// ① 非法 id 拒写 + 报错带合法清单 ② 合法 id 通过 ③ 空串清除放行 ④ llm 缺失降级放行
|
|
6027
|
+
// ⑤ 熔断通知分态:UNKNOWN_MODEL → 「配置非法(不在可用清单)」≠「验收连续失败」
|
|
6028
|
+
function mkLlmCtx(modelsByProvider) {
|
|
6029
|
+
// modelsByProvider: { providerId: [{id, name}] }——对齐宿主 llm 服务契约(listProviders/listModels)
|
|
6030
|
+
return {
|
|
6031
|
+
get: function (name) {
|
|
6032
|
+
if (name !== 'llm') return null
|
|
6033
|
+
return {
|
|
6034
|
+
listProviders: function () { return Object.keys(modelsByProvider).map(function (id) { return { id: id, name: id } }) },
|
|
6035
|
+
listModels: async function (p) { return modelsByProvider[p] || [] },
|
|
6036
|
+
}
|
|
6037
|
+
},
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
const MOCK_MODELS = { deepseek: [{ id: 'deepseek-chat', name: 'DeepSeek Chat' }, { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner' }], kimi: [{ id: 'kimi-k2', name: 'Kimi K2' }] }
|
|
6041
|
+
|
|
6042
|
+
test('set-board-config 模型校验①:非法 id 拒写 + 报错带合法清单,看板不落脏值', async () => {
|
|
6043
|
+
const board = mkBoard([])
|
|
6044
|
+
const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
|
|
6045
|
+
// 实证原形:显示名「kimi/kimi-k3」抄成 id(合法的是 kimi/kimi-k2)
|
|
6046
|
+
const r = await h['set-board-config']({ key: 'verifierModel', value: 'kimi/kimi-k3' })
|
|
6047
|
+
assert.equal(r.ok, false)
|
|
6048
|
+
assert.match(r.error, /非法模型 id: kimi\/kimi-k3/)
|
|
6049
|
+
assert.match(r.error, /不在当前网关可用清单/)
|
|
6050
|
+
assert.match(r.error, /合法清单(3 个,示前 3 个)/) // 总数 + 截断计数透出
|
|
6051
|
+
assert.match(r.error, /deepseek\/deepseek-chat/) // 清单样例带合法 id
|
|
6052
|
+
assert.match(r.error, /list-models/) // 指引完整清单入口
|
|
6053
|
+
assert.equal(board.verifierModel, undefined) // 拒写:看板字段未被污染
|
|
6054
|
+
// workerModel 同款门禁
|
|
6055
|
+
const r2 = await h['set-board-config']({ key: 'workerModel', value: 'openai/gpt-99' })
|
|
6056
|
+
assert.equal(r2.ok, false)
|
|
6057
|
+
assert.match(r2.error, /非法模型 id/)
|
|
6058
|
+
assert.equal(board.workerModel, undefined)
|
|
6059
|
+
})
|
|
6060
|
+
|
|
6061
|
+
test('set-board-config 模型校验②:合法 id 双字段通过落盘', async () => {
|
|
6062
|
+
const board = mkBoard([])
|
|
6063
|
+
const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
|
|
6064
|
+
const r = await h['set-board-config']({ key: 'verifierModel', value: 'kimi/kimi-k2' })
|
|
6065
|
+
assert.equal(r.ok, true)
|
|
6066
|
+
assert.equal(board.verifierModel, 'kimi/kimi-k2')
|
|
6067
|
+
const r2 = await h['set-board-config']({ key: 'workerModel', value: ' deepseek/deepseek-reasoner ' }) // 首尾空格 trim 后命中
|
|
6068
|
+
assert.equal(r2.ok, true)
|
|
6069
|
+
assert.equal(board.workerModel, 'deepseek/deepseek-reasoner')
|
|
6070
|
+
})
|
|
6071
|
+
|
|
6072
|
+
test('set-board-config 模型校验③:空串/非字符串=清除覆盖,放行不校验', async () => {
|
|
6073
|
+
const board = mkBoard([])
|
|
6074
|
+
board.verifierModel = 'kimi/kimi-k2'
|
|
6075
|
+
const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
|
|
6076
|
+
const r = await h['set-board-config']({ key: 'verifierModel', value: '' })
|
|
6077
|
+
assert.equal(r.ok, true)
|
|
6078
|
+
assert.equal(board.verifierModel, '')
|
|
6079
|
+
const r2 = await h['set-board-config']({ key: 'verifierModel', value: undefined })
|
|
6080
|
+
assert.equal(r2.ok, true)
|
|
6081
|
+
assert.equal(board.verifierModel, '')
|
|
6082
|
+
// 非模型键不受枚举影响(无 llm 也照常写)
|
|
6083
|
+
const h2 = mkRpcHandlers(board)
|
|
6084
|
+
const r3 = await h2['set-board-config']({ key: 'maxWorkers', value: 5 })
|
|
6085
|
+
assert.equal(r3.ok, true)
|
|
6086
|
+
assert.equal(board.maxWorkers, 5)
|
|
6087
|
+
})
|
|
6088
|
+
|
|
6089
|
+
test('set-board-config 模型校验④:llm 服务缺失(老宿主)降级放行,不炸', async () => {
|
|
6090
|
+
const board = mkBoard([])
|
|
6091
|
+
// 显式给「有 get 但 llm 缺席」的老宿主形态(mkRpcHandlers 默认 ctx 无 get 方法,生产 ctx.get 恒存在)
|
|
6092
|
+
const hNoLlm = mkRpcHandlers(board, {}, { get: function () { return null } })
|
|
6093
|
+
const r = await hNoLlm['set-board-config']({ key: 'verifierModel', value: 'whatever/display-name' })
|
|
6094
|
+
assert.equal(r.ok, true) // 降级放行:不校验直接写
|
|
6095
|
+
assert.equal(board.verifierModel, 'whatever/display-name')
|
|
6096
|
+
// list-models 在同形态下报服务不可用(既有契约不变)
|
|
6097
|
+
const lm = await hNoLlm['list-models']({})
|
|
6098
|
+
assert.equal(lm.ok, false)
|
|
6099
|
+
assert.match(lm.error, /llm service unavailable/)
|
|
6100
|
+
})
|
|
6101
|
+
|
|
6102
|
+
test('set-board-config 模型校验④b:provider 枚举失败不阻塞,其余 provider 清单仍可校验', async () => {
|
|
6103
|
+
const board = mkBoard([])
|
|
6104
|
+
const ctxExtra = {
|
|
6105
|
+
get: function (name) {
|
|
6106
|
+
if (name !== 'llm') return null
|
|
6107
|
+
return {
|
|
6108
|
+
listProviders: function () { return [{ id: 'broken', name: 'B' }, { id: 'kimi', name: 'K' }] },
|
|
6109
|
+
listModels: async function (p) { if (p === 'broken') throw new Error('gateway boom'); return [{ id: 'kimi-k2' }] },
|
|
6110
|
+
}
|
|
6111
|
+
},
|
|
6112
|
+
}
|
|
6113
|
+
const h = mkRpcHandlers(board, {}, ctxExtra)
|
|
6114
|
+
const rBad = await h['set-board-config']({ key: 'workerModel', value: 'broken/any' }) // 枚举失败的 provider 清单缺席 → 拒
|
|
6115
|
+
assert.equal(rBad.ok, false)
|
|
6116
|
+
assert.match(rBad.error, /kimi\/kimi-k2/)
|
|
6117
|
+
const rOk = await h['set-board-config']({ key: 'workerModel', value: 'kimi/kimi-k2' })
|
|
6118
|
+
assert.equal(rOk.ok, true)
|
|
6119
|
+
assert.equal(board.workerModel, 'kimi/kimi-k2')
|
|
6120
|
+
})
|
|
6121
|
+
|
|
6122
|
+
// 熔断通知分态行为测试 harness:一次性 verifier run 失败,捕获 pushSysNote 文案 + badModels 落账
|
|
6123
|
+
async function runVerifierFuseCase(errText) {
|
|
6124
|
+
const t = mkTask({ id: 'v-fuse', title: '待验收卡', status: 'verifying' })
|
|
6125
|
+
const board = mkBoard([t])
|
|
6126
|
+
board.verifierModel = 'kimi/kimi-k3' // 已写入的(非法)模型覆盖 → rec.model 非空才会触发熔断
|
|
6127
|
+
const notes = []
|
|
6128
|
+
const runs = {}
|
|
6129
|
+
const activeRuns = { [FULL_SID]: runs }
|
|
6130
|
+
const badModels = {}
|
|
6131
|
+
const listeners = []
|
|
6132
|
+
const ctx = {
|
|
6133
|
+
fs: {}, get: function () { return null }, timer: null,
|
|
6134
|
+
effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
|
|
6135
|
+
on: function (name, fn) { listeners.push({ name: name, fn: fn }); return function () {} },
|
|
6136
|
+
subagents: {
|
|
6137
|
+
list: () => ['mock'],
|
|
6138
|
+
getProvider: () => ({ inheritsParentContext: false }),
|
|
6139
|
+
// spawn 本身成功(半建会话不存在的问题在 provider 层),run.result 随后以给定错误落定失败
|
|
6140
|
+
start: async () => ({ id: 'run-fuse', result: new Promise(function (res, rej) { setImmediate(function () { rej(new Error(errText)) }) }), dispose: async function () {} }),
|
|
6141
|
+
startContinuable: async () => ({ childId: 'child-x', messageId: 'm1' }),
|
|
6142
|
+
},
|
|
6143
|
+
}
|
|
6144
|
+
const dispatch = createDispatch(ctx, { knownSessions: {}, dispatchedEver: {}, badModels: badModels, teamModeCache: {}, activeRuns: activeRuns }, {
|
|
6145
|
+
rt: async () => board, wt: async () => {}, mutateLocked: async (sid, fn) => fn(board), kickCycle: () => {},
|
|
6146
|
+
rootForSession: () => ({ id: FULL_SID }),
|
|
6147
|
+
withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
|
|
6148
|
+
pushSysNote: (sid, text) => { notes.push(text) }, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
6149
|
+
})
|
|
6150
|
+
await dispatch.poolCycle(FULL_SID)
|
|
6151
|
+
assert.ok(runs['v-fuse'], 'verifier run 应已登记活跃表')
|
|
6152
|
+
// 等 run.result 拒绝 → settleRun → settleVerifier 链落定
|
|
6153
|
+
const flushN = () => new Promise(r => setImmediate(r))
|
|
6154
|
+
for (var i = 0; i < 8; i++) await flushN()
|
|
6155
|
+
return { notes: notes, badModels: badModels, task: t }
|
|
6156
|
+
}
|
|
6157
|
+
|
|
6158
|
+
test('模型熔断分态①:UNKNOWN_MODEL → 「配置非法(不在可用清单)」,不误报连续失败', async () => {
|
|
6159
|
+
const r = await runVerifierFuseCase('spawn failed: UNKNOWN_MODEL: kimi/kimi-k3 (model not configured)')
|
|
6160
|
+
assert.equal(r.badModels[FULL_SID + '|kimi/kimi-k3'], true) // 熔断照常落账(下轮回退父级)
|
|
6161
|
+
assert.equal(r.notes.length, 1)
|
|
6162
|
+
assert.match(r.notes[0], /模型 kimi\/kimi-k3 配置非法(不在可用清单),已熔断回退父级模型/)
|
|
6163
|
+
assert.match(r.notes[0], /list-models/) // 指引重配入口
|
|
6164
|
+
assert.ok(!/验收连续失败/.test(r.notes[0]), 'mistake 类不得误报为 quality 类')
|
|
6165
|
+
assert.equal(r.task.verifyRetries, 1) // 重试计数语义不变
|
|
6166
|
+
})
|
|
6167
|
+
|
|
6168
|
+
test('模型熔断分态②:非 UNKNOWN_MODEL 失败 → 保持「验收连续失败」原文案', async () => {
|
|
6169
|
+
const r = await runVerifierFuseCase('provider timeout after 30s')
|
|
6170
|
+
assert.equal(r.badModels[FULL_SID + '|kimi/kimi-k3'], true)
|
|
6171
|
+
assert.equal(r.notes.length, 1)
|
|
6172
|
+
assert.match(r.notes[0], /模型 kimi\/kimi-k3 验收连续失败,已熔断回退父级模型/)
|
|
6173
|
+
assert.ok(!/配置非法/.test(r.notes[0]))
|
|
6174
|
+
})
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
|
|
11
11
|
// · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
|
|
12
12
|
// · agents.currentInitiator()/roots()/list()/get()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root;env.setRoots 可清空模拟休眠)
|
|
13
|
-
// · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession
|
|
13
|
+
// · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话;
|
|
14
|
+
// env.persistedSizes[id]=N 可让快照带 sizeBytes——cleanup 体积主源断言用)
|
|
14
15
|
// · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
|
|
15
16
|
// · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
|
|
16
17
|
// · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
|
|
@@ -51,6 +52,7 @@ import { Readable } from 'node:stream'
|
|
|
51
52
|
* @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
|
|
52
53
|
* @property {(list:Object[])=>void} setRoots 改写活跃 root 集(传 [] 模拟休眠根会话/重建窗口)
|
|
53
54
|
* @property {Object} persistedHeaders 持久会话表 sid → { id, parentSession }(写 parentSession 模拟子会话;缺省根会话 parentSession=undefined)
|
|
55
|
+
* @property {Object} persistedSizes 快照体积表 sid → sizeBytes 数字(cleanup 体积主源断言用;缺省=快照无 sizeBytes → 目录遍历回退)
|
|
54
56
|
* @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
|
|
55
57
|
* @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
|
|
56
58
|
* @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
|
|
@@ -194,6 +196,7 @@ export function createMockCtx(opts) {
|
|
|
194
196
|
var rootsList = [root] // 活跃 root 集(测试可 env.setRoots([]) 清空模拟「休眠根会话」)
|
|
195
197
|
var persistedHeaders = {} // 持久会话表 sid → SessionHeader(测试可写 parentSession 模拟子会话/根会话)
|
|
196
198
|
persistedHeaders[sid] = { id: sid, parentSession: undefined }
|
|
199
|
+
var persistedSizes = {} // 快照体积表 sid → sizeBytes(task-mv39ag1e:cleanup 体积主源断言用;缺省=快照无 sizeBytes,走目录遍历回退)
|
|
197
200
|
var agents = {
|
|
198
201
|
currentInitiator: function () { return { id: env.actor } },
|
|
199
202
|
roots: function () { return rootsList },
|
|
@@ -237,7 +240,8 @@ export function createMockCtx(opts) {
|
|
|
237
240
|
}
|
|
238
241
|
if (name === 'sessionPersistence') {
|
|
239
242
|
return {
|
|
240
|
-
|
|
243
|
+
// 快照形态对齐官方 SessionPersistenceSnapshot:sizeBytes 仅当后端能廉价提供时才带(未登记=缺省字段)
|
|
244
|
+
stat: async function (id) { var h = persistedHeaders[id]; if (!h) return undefined; var snap = { header: h }; if (typeof persistedSizes[id] === 'number') snap.sizeBytes = persistedSizes[id]; return snap },
|
|
241
245
|
}
|
|
242
246
|
}
|
|
243
247
|
return undefined
|
|
@@ -274,6 +278,7 @@ export function createMockCtx(opts) {
|
|
|
274
278
|
actor: sid,
|
|
275
279
|
setRoots: function (list) { rootsList = list || [] }, // 清空/改写活跃 root 集(模拟休眠/重建窗口)
|
|
276
280
|
persistedHeaders: persistedHeaders, // 持久会话表(sid → { id, parentSession });写 parentSession 模拟子会话
|
|
281
|
+
persistedSizes: persistedSizes, // 快照体积表(sid → sizeBytes 数字);cleanup 体积主源断言用
|
|
277
282
|
|
|
278
283
|
// 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
|
|
279
284
|
rpc: async function (method, args) {
|