dsh-agent-board 1.7.2 → 1.7.3
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 +27 -10
- package/index.mjs +10 -4
- package/lib/client/dashboard.js +58 -17
- package/lib/client/kernel.js +44 -6
- package/lib/client/task-detail.js +7 -3
- package/lib/client.js +109 -26
- package/lib/core.mjs +61 -29
- package/lib/dispatch.mjs +920 -226
- package/lib/rpc.mjs +113 -17
- package/lib/store.mjs +26 -1
- package/lib/usage.mjs +160 -20
- package/package.json +1 -1
- package/test/core.test.mjs +1980 -104
package/test/core.test.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs'
|
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import zlib from 'node:zlib'
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
10
11
|
import * as core from '../lib/core.mjs'
|
|
11
12
|
// 粒度治理(软闸门)住在 lib/policy.mjs(策略层,纯函数)、usage 聚合住在 lib/usage.mjs,
|
|
12
13
|
// 均由 index.mjs 薄壳 re-export(对外契约不变),这里仍从 index.mjs 导入直接断言
|
|
@@ -270,78 +271,164 @@ test('buildVerifierPrompt: 注入交付物 + 验收脚本 + messages', () => {
|
|
|
270
271
|
assert.match(p, /做完了/); assert.match(p, /npm test/); assert.match(p, /board_verdict/); assert.match(p, /方案B/)
|
|
271
272
|
})
|
|
272
273
|
|
|
273
|
-
// =====
|
|
274
|
+
// ===== 预研上下文段(瘦身分离形态,task-muvjs392):笔记全文 + 文件清单,不含文件内容本体 =====
|
|
275
|
+
// 形态:### 主窗口调研笔记(全文 ≤8000 字符)\n…\n\n### 调研文件清单(- 路径:L起-L止 — 一句用途)
|
|
274
276
|
test('buildContextPackSection: 空清单返回空串', () => {
|
|
275
277
|
assert.equal(core.buildContextPackSection([]), '')
|
|
276
278
|
assert.equal(core.buildContextPackSection(null), '')
|
|
277
279
|
assert.equal(core.buildContextPackSection(undefined), '')
|
|
278
280
|
})
|
|
279
281
|
|
|
280
|
-
test('
|
|
282
|
+
test('parseContextFileEntry: 路径/锚点归一 + 可选「 — 一句用途」', () => {
|
|
283
|
+
assert.deepEqual(core.parseContextFileEntry('src/a.js'), { path: 'src/a.js', file: 'src/a.js', usage: '' })
|
|
284
|
+
assert.deepEqual(core.parseContextFileEntry('src/a.js:L2350-L2420'), { path: 'src/a.js:L2350-L2420', file: 'src/a.js', usage: '' })
|
|
285
|
+
// 第二段 L 可省略 → 归一成 :L10-L20(清单里给 Worker 的行号形态统一)
|
|
286
|
+
assert.deepEqual(core.parseContextFileEntry('src/a.js:L10-20'), { path: 'src/a.js:L10-L20', file: 'src/a.js', usage: '' })
|
|
287
|
+
// 「 — 一句用途」(em dash 两侧空格):用途与路径/锚点一起剥出来
|
|
288
|
+
assert.deepEqual(core.parseContextFileEntry('lib/x.mjs:L112-L116 — 派发引擎锚点行段'), { path: 'lib/x.mjs:L112-L116', file: 'lib/x.mjs', usage: '派发引擎锚点行段' })
|
|
289
|
+
assert.deepEqual(core.parseContextFileEntry('docs/a.md — 设计文档'), { path: 'docs/a.md', file: 'docs/a.md', usage: '设计文档' })
|
|
290
|
+
// Windows 盘符 + 锚点 + 用途共存
|
|
291
|
+
assert.deepEqual(core.parseContextFileEntry('C:\\w\\a.js:L100-L200 — 入口'), { path: 'C:\\w\\a.js:L100-L200', file: 'C:\\w\\a.js', usage: '入口' })
|
|
292
|
+
// 非法锚点剥掉(不误导子代理去读空段);空串安全
|
|
293
|
+
assert.deepEqual(core.parseContextFileEntry('a.js:L0'), { path: 'a.js', file: 'a.js', usage: '' })
|
|
294
|
+
assert.deepEqual(core.parseContextFileEntry(''), { path: '', file: '', usage: '' })
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('buildContextPackSection: 文件清单行(路径+行号+用途),不含内容本体', () => {
|
|
281
298
|
const s = core.buildContextPackSection([
|
|
282
|
-
{ path: 'src/a.ts',
|
|
283
|
-
{ path: 'docs/b.md'
|
|
299
|
+
{ path: 'src/a.ts:L1-L40', usage: '状态机主循环' },
|
|
300
|
+
{ path: 'docs/b.md' },
|
|
284
301
|
])
|
|
285
|
-
assert.match(s,
|
|
286
|
-
assert.match(s,
|
|
287
|
-
assert.match(s,
|
|
302
|
+
assert.match(s, /调研文件清单/); assert.match(s, /不要全量盲读/)
|
|
303
|
+
assert.match(s, /- src\/a\.ts:L1-L40 — 状态机主循环/)
|
|
304
|
+
assert.match(s, /- docs\/b\.md/) // 无用途 → 只给路径行号
|
|
305
|
+
// 条目上的老字段(content/truncated/meta/outline)一律不再被消费:正文绝不进清单
|
|
306
|
+
const s2 = core.buildContextPackSection([
|
|
307
|
+
{ path: 'src/big.js:L2350-L2420', usage: '目标段', content: 'const LEGACY_BODY = 1', truncated: true, meta: '锚点行段:共 3800 行,已注入 L2350–L2420', outline: ['L12: export function foo()'] },
|
|
308
|
+
])
|
|
309
|
+
assert.match(s2, /- src\/big\.js:L2350-L2420 — 目标段/)
|
|
310
|
+
assert.doesNotMatch(s2, /LEGACY_BODY/) // 文件内容本体不进 prompt
|
|
311
|
+
assert.doesNotMatch(s2, /结构索引/) // 不再附结构索引块
|
|
312
|
+
assert.doesNotMatch(s2, /锚点行段:共 3800 行/) // 不再有读盘产出的 meta
|
|
288
313
|
})
|
|
289
314
|
|
|
290
|
-
test('
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
assert.match(
|
|
294
|
-
assert.match(core.buildWorkerPrompt(t, pack), /hello/)
|
|
295
|
-
assert.match(core.buildVerifierPrompt(t, pack), /### src\/x\.js/)
|
|
296
|
-
// 不传 pack 时不出现该段
|
|
297
|
-
assert.doesNotMatch(core.buildWorkerPrompt(t), /主窗口预研文件/)
|
|
315
|
+
test('buildContextPackSection: 裸字符串条目兼容(老调用方零改)+ 清单与笔记同现', () => {
|
|
316
|
+
const s = core.buildContextPackSection(['src/x.js'], '结论:先改 A 再改 B')
|
|
317
|
+
assert.match(s, /- src\/x\.js/)
|
|
318
|
+
assert.match(s, /主窗口调研笔记/); assert.match(s, /结论:先改 A 再改 B/)
|
|
298
319
|
})
|
|
299
320
|
|
|
300
|
-
test('
|
|
301
|
-
const
|
|
321
|
+
test('buildWorkerPrompt/buildVerifierPrompt: 预研清单进 prompt(笔记全文 + 清单行,不含文件内容本体)', () => {
|
|
322
|
+
const pack = core.buildContextPackSection(
|
|
323
|
+
[{ path: 'lib/core.mjs:L100-L140', usage: '清单组装与锚点归一', content: 'export function buildContextPackSection(files, notes) {' }],
|
|
324
|
+
'根因:调研包随快照每轮重发 6×48.8K 字符'
|
|
325
|
+
)
|
|
326
|
+
const t = mkTask({ id: 'pk' })
|
|
327
|
+
const pw = core.buildWorkerPrompt(t, pack)
|
|
328
|
+
assert.match(pw, /### 主窗口调研笔记/); assert.match(pw, /根因:调研包随快照每轮重发 6×48\.8K 字符/) // 笔记全文
|
|
329
|
+
assert.match(pw, /- lib\/core\.mjs:L100-L140 — 清单组装与锚点归一/) // 清单行(含行号+用途)
|
|
330
|
+
assert.match(pw, /按需用 read 工具自行读取/) // 自取指引
|
|
331
|
+
assert.doesNotMatch(pw, /export function buildContextPackSection/) // 文件内容本体不在 prompt
|
|
332
|
+
const pv = core.buildVerifierPrompt(t, pack)
|
|
333
|
+
assert.match(pv, /- lib\/core\.mjs:L100-L140 — 清单组装与锚点归一/)
|
|
334
|
+
assert.doesNotMatch(pv, /export function buildContextPackSection/)
|
|
335
|
+
// 不传 pack 时不出现该段(无清单 parity:与注入迁移前逐字同形)
|
|
336
|
+
assert.doesNotMatch(core.buildWorkerPrompt(t), /调研文件清单/)
|
|
337
|
+
assert.doesNotMatch(core.buildVerifierPrompt(t), /调研文件清单/)
|
|
338
|
+
assert.doesNotMatch(core.buildWorkerPrompt(t), /主窗口调研笔记/)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('buildContextPackSection: {{ }} 插值净化(严格插值时代遗留口径,封闭变换)', () => {
|
|
342
|
+
const s = core.buildContextPackSection([{ path: 'src/tpl.vue' }], '<div>{{ msg }}</div>')
|
|
302
343
|
assert.doesNotMatch(s, /\{\{/) // 不允许残留插值触发器
|
|
303
344
|
assert.match(s, /\{ \{ msg \}\}/) // 内容可读性保留
|
|
304
345
|
})
|
|
305
346
|
|
|
306
347
|
test('buildContextPackSection: 三连花括号(Python f-string)封闭净化', () => {
|
|
307
348
|
// 真实事故:f"{{{lo}}}" 经 replace(/\{\{/g,'{ {') 变成 "{ {{lo}}}"——替换结果自己又造出 {{
|
|
308
|
-
const s = core.buildContextPackSection([
|
|
349
|
+
const s = core.buildContextPackSection([], 'quant = f"{{{lo}}}" + f"{{{lo},{hi}}}"')
|
|
309
350
|
assert.doesNotMatch(s, /\{\{/) // 净化必须是封闭变换
|
|
310
351
|
assert.match(s, /\{ \{ \{lo\}\}\}/) // 可读性保留
|
|
311
352
|
// 五连括号 + 单括号混合
|
|
312
|
-
const s2 = core.buildContextPackSection([
|
|
353
|
+
const s2 = core.buildContextPackSection([], 'a{{{{{b}}}}}{c}{{d}}')
|
|
313
354
|
assert.doesNotMatch(s2, /\{\{/)
|
|
314
355
|
})
|
|
315
356
|
|
|
316
|
-
test('buildContextPackSection: 笔记(思路/原始需求)注入 + 净化', () => {
|
|
317
|
-
const s = core.buildContextPackSection([{ path: 'a.js',
|
|
357
|
+
test('buildContextPackSection: 笔记(思路/原始需求)注入 + 净化 + 清单同现', () => {
|
|
358
|
+
const s = core.buildContextPackSection([{ path: 'a.js:L1-L9', usage: '入口' }], '用户原话:要做成{{可配置}}的')
|
|
318
359
|
assert.match(s, /主窗口调研笔记/)
|
|
319
360
|
assert.match(s, /用户原话:要做成\{ \{可配置\}\}的/) // 笔记里的 {{}} 也被净化
|
|
320
|
-
assert.match(s,
|
|
361
|
+
assert.match(s, /- a\.js:L1-L9 — 入口/) // 清单行同时存在
|
|
321
362
|
})
|
|
322
363
|
|
|
323
|
-
test('buildContextPackSection:
|
|
364
|
+
test('buildContextPackSection: 仅笔记无文件也可注入(不出现清单段)', () => {
|
|
324
365
|
const s = core.buildContextPackSection([], '思路:先改 A 再改 B')
|
|
325
366
|
assert.match(s, /主窗口调研笔记/); assert.match(s, /先改 A 再改 B/)
|
|
326
|
-
assert.doesNotMatch(s,
|
|
367
|
+
assert.doesNotMatch(s, /调研文件清单/)
|
|
327
368
|
assert.equal(core.buildContextPackSection([], ''), '')
|
|
328
369
|
assert.equal(core.buildContextPackSection(null, ' '), '')
|
|
329
370
|
})
|
|
330
371
|
|
|
331
|
-
test('
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
assert.
|
|
336
|
-
assert.
|
|
372
|
+
test('注入迁移(task-muvjs392):清单进首条 prompt,注入区块通道与认领机制全清零(源码级断言)', () => {
|
|
373
|
+
const src = hostSrc()
|
|
374
|
+
// ① 通道退役:不再注册 systemPrompt.context 动态注入段(Team 引导段 section 照旧在位)
|
|
375
|
+
assert.doesNotMatch(src, /task-board:context-pack/)
|
|
376
|
+
assert.doesNotMatch(src, /packByChild|pendingPacks/)
|
|
377
|
+
assert.doesNotMatch(src, /sysPrompt\.context\(/)
|
|
378
|
+
assert.match(src, /name: 'task-board:team-mode'/)
|
|
379
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
380
|
+
const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
|
|
381
|
+
assert.doesNotMatch(idx, /packByChild|pendingPacks/) // state 容器本体清零
|
|
382
|
+
// ② 派发侧零读盘:不再读文件、不再切段/附索引、不再按会话工作区解析
|
|
383
|
+
assert.doesNotMatch(dsp, /packByChild|pendingPacks|packNote/)
|
|
384
|
+
assert.doesNotMatch(dsp, /ctx\.fs|fs\.readText|sliceLines|buildFileOutline/)
|
|
385
|
+
// ③ 瘦身清单本体直接拼进 worker/verifier 两态 prompt(三态其余不变:hook 仍走 buildHookPrompt)
|
|
386
|
+
assert.match(dsp, /buildWorkerPrompt\(t, pack, cfg\(dsnap\)\.feedbackEnabled\)/)
|
|
387
|
+
assert.match(dsp, /buildVerifierPrompt\(t, pack\)/)
|
|
388
|
+
assert.match(dsp, /buildHookPrompt\(t, role === 'hook-pre' \? 'pre' : 'post', kids\)/)
|
|
389
|
+
// ④ readContextPack = 组装清单(不再 await 读盘)——派发点与 rpc 预览点都直接调用
|
|
390
|
+
assert.match(dsp, /function readContextPack\(t\) \{/)
|
|
391
|
+
assert.match(dsp, /return buildContextPackSection\(files, notes\.slice\(0, 8000\)\)/)
|
|
337
392
|
})
|
|
338
393
|
|
|
339
|
-
test('
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
394
|
+
test('task_preview_context / preview-context:返回瘦身清单形态(笔记全文 + 清单行;无文件正文)', async () => {
|
|
395
|
+
// 真实 readContextPack 出自 dispatch(组装清单,零 IO):用最小 ctx/deps 造一份
|
|
396
|
+
const dispatch = createDispatch(
|
|
397
|
+
{ effect: function () {}, get: function () { return null } },
|
|
398
|
+
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} },
|
|
399
|
+
{
|
|
400
|
+
rt: async () => mkBoard([]), wt: async () => {}, mutateLocked: async (sid, fn) => fn(mkBoard([])), kickCycle: () => {},
|
|
401
|
+
rootForSession: () => null, withTimeout: (p) => p, runsFor: () => ({}), feedbackOn: () => true,
|
|
402
|
+
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
403
|
+
})
|
|
404
|
+
const h = mkRpcHandlers(mkBoard([]), { readContextPack: dispatch.readContextPack })
|
|
405
|
+
const files = ['lib/core.mjs:L1-L60 — 清单组装与锚点归一', 'lib/dispatch.mjs']
|
|
406
|
+
const r1 = await h.__tools['task_preview_context'].execute({ contextFiles: files, contextNotes: '结论:先改 A' })
|
|
407
|
+
assert.equal(r1.ok, true); assert.equal(r1.empty, false)
|
|
408
|
+
assert.match(r1.pack, /### 主窗口调研笔记/); assert.match(r1.pack, /结论:先改 A/)
|
|
409
|
+
assert.match(r1.pack, /- lib\/core\.mjs:L1-L60 — 清单组装与锚点归一/)
|
|
410
|
+
assert.match(r1.pack, /- lib\/dispatch\.mjs/)
|
|
411
|
+
assert.doesNotMatch(r1.pack, /export function buildContextPackSection/) // 正文不进预览(0.1.7 起不再读盘)
|
|
412
|
+
const r2 = await h['preview-context']({ contextFiles: files })
|
|
413
|
+
assert.equal(r2.ok, true); assert.equal(r2.filesCount, 2)
|
|
414
|
+
assert.match(r2.pack, /- lib\/core\.mjs:L1-L60 — 清单组装与锚点归一/)
|
|
415
|
+
assert.equal((await h['preview-context']({})).ok, false) // 两样都不给 → 明确报错(既有口径)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('派发接线:Worker 首条 prompt 带瘦身清单(笔记全文 + 清单行),不含文件内容本体', async () => {
|
|
419
|
+
// 真跑一次 spawn(poolCycle → spawnOneShot → buildWorkerPrompt),捕获子代理实收 prompt。
|
|
420
|
+
// ctx.fs 只给空对象:清单组装若还试图读盘会立刻炸——顺带锁定「IO 清零」。
|
|
421
|
+
const t = mkTask({ id: 'c1', status: 'pending', context: { files: ['lib/core.mjs:L100-L140 — 清单组装与锚点归一'], notes: '调研结论:走瘦身分离,文件正文由我自己 read' } })
|
|
422
|
+
const h = mkHookDispatch(mkBoard([t]))
|
|
423
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
424
|
+
// Worker 默认走 continuable 路径(workerContinuable 缺省 true):从 spawnedContinuable 取实收 prompt
|
|
425
|
+
const w = h.spawnedContinuable.find((s) => s.label === 'worker:c1')
|
|
426
|
+
assert.ok(w, 'Worker 已按真实派发路径 spawn(continuable)')
|
|
427
|
+
assert.match(w.text, /### 主窗口调研笔记/); assert.match(w.text, /调研结论:走瘦身分离,文件正文由我自己 read/) // 笔记全文
|
|
428
|
+
assert.match(w.text, /### 调研文件清单/); assert.match(w.text, /- lib\/core\.mjs:L100-L140 — 清单组装与锚点归一/) // 清单行
|
|
429
|
+
assert.match(w.text, /按需用 read 工具自行读取,不要全量盲读/) // 自取指引
|
|
430
|
+
assert.doesNotMatch(w.text, /export function buildContextPackSection/) // 文件内容本体不在 prompt
|
|
431
|
+
assert.doesNotMatch(w.text, /通过「上下文注入」区提供/) // 旧指引句已退役
|
|
345
432
|
})
|
|
346
433
|
|
|
347
434
|
// ===== 锚点行段解析(②,零依赖纯正则,Windows 盘符冒号不得误判)=====
|
|
@@ -578,43 +665,47 @@ test('touchesConflict: 与锁持有者逐条比对,返回冲突持有者 id',
|
|
|
578
665
|
assert.deepEqual(core.touchesConflict({ id: 'h1', touches: ['src/a.js'] }, holds), []) // 不和自己冲突
|
|
579
666
|
})
|
|
580
667
|
|
|
581
|
-
test('holdsFiles: in-progress(claimedBy) / verifying /
|
|
668
|
+
test('holdsFiles: in-progress(claimedBy) / verifying 两态持有文件锁,resolved/cancelled/归档即放', () => {
|
|
582
669
|
const tasks = [
|
|
583
670
|
mkTask({ id: 'live', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
|
|
584
671
|
mkTask({ id: 'notouch', status: 'in-progress', claimedBy: 'run-2' }),
|
|
585
672
|
mkTask({ id: 'noclaim', status: 'in-progress' }),
|
|
586
673
|
mkTask({ id: 'pending', status: 'pending', touches: ['src/a.js'] }),
|
|
587
|
-
//
|
|
674
|
+
// 锁只护「正在写」的阶段:in-progress→verifying 不断锁(驳回会回 in-progress 继续改同一批文件)
|
|
588
675
|
mkTask({ id: 'verifying', status: 'verifying', touches: ['src/b.js'] }),
|
|
589
|
-
//
|
|
676
|
+
// task-muwbtee1 / 用户 2026-10-06 裁决:状态流转到已完成(resolved)即放锁,未归档也放;
|
|
677
|
+
// cancelled(放弃语义)与 archived(归档=纯收纳动作)同样不持锁。
|
|
590
678
|
mkTask({ id: 'resolved', status: 'resolved', touches: ['src/c.js'] }),
|
|
679
|
+
mkTask({ id: 'cancelled', status: 'cancelled', touches: ['src/d.js'] }),
|
|
680
|
+
mkTask({ id: 'archived', status: 'archived', touches: ['src/e.js'] }),
|
|
591
681
|
mkTask({ id: 'empty', status: 'in-progress', claimedBy: 'run-3', touches: [] }),
|
|
592
682
|
]
|
|
593
683
|
const holds = core.holdsFiles(mkBoard(tasks))
|
|
594
|
-
assert.deepEqual(holds.map(h => h.id), ['live', 'verifying'
|
|
684
|
+
assert.deepEqual(holds.map(h => h.id), ['live', 'verifying'])
|
|
595
685
|
assert.deepEqual(holds[0].touches, ['src/**'])
|
|
596
686
|
assert.deepEqual(core.holdsFiles(null), [])
|
|
597
687
|
})
|
|
598
688
|
|
|
599
|
-
// ===== touches
|
|
600
|
-
test('touches 持锁①:resolved
|
|
689
|
+
// ===== touches 锁随工作态:resolved 即放(task-muwbtee1;用户 2026-10-06 裁决,回调 task-muv7c8ja)四类断言 =====
|
|
690
|
+
test('touches 持锁①:resolved(未归档)即放锁 → 同 touches 候选下一轮自动放行', () => {
|
|
601
691
|
const holder = mkTask({ id: 'holder', status: 'resolved', touches: ['src/**'] })
|
|
602
692
|
const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
|
|
603
693
|
const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
|
|
604
|
-
assert.deepEqual(r.pendings, [])
|
|
605
|
-
assert.deepEqual(r.blockedTouches, [
|
|
606
|
-
// 不依赖候选自身状态:resolved
|
|
607
|
-
assert.deepEqual(core.holdsFiles(mkBoard([holder]))
|
|
694
|
+
assert.deepEqual(r.pendings.map(t => t.id), ['cand']) // 完成即放:候选不再被 resolved 卡滞留
|
|
695
|
+
assert.deepEqual(r.blockedTouches, []) // 不产生「等 resolved 卡」的等待展示态
|
|
696
|
+
// 不依赖候选自身状态:resolved 不持锁只与状态口径有关(归档与否无关)
|
|
697
|
+
assert.deepEqual(core.holdsFiles(mkBoard([holder])), [])
|
|
608
698
|
})
|
|
609
699
|
|
|
610
|
-
test('touches
|
|
700
|
+
test('touches 持锁②:归档回归纯收纳动作——archived 与 resolved 一样不持锁(不再是唯一真释放点)', () => {
|
|
611
701
|
const holder = mkTask({ id: 'holder', status: 'resolved', touches: ['src/**'] })
|
|
612
702
|
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'
|
|
703
|
+
assert.deepEqual(core.pickDispatch(mkBoard([holder, cand]), 5, 0, null).pendings.map(t => t.id), ['cand'])
|
|
704
|
+
holder.status = 'archived' // 归档只是收纳
|
|
615
705
|
const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
|
|
616
|
-
assert.deepEqual(r.pendings.map(t => t.id), ['cand'])
|
|
706
|
+
assert.deepEqual(r.pendings.map(t => t.id), ['cand']) // 放行结果与 resolved 态逐字相同(幂等无影响)
|
|
617
707
|
assert.deepEqual(r.blockedTouches, [])
|
|
708
|
+
assert.deepEqual(core.holdsFiles(mkBoard([holder])), [])
|
|
618
709
|
})
|
|
619
710
|
|
|
620
711
|
test('touches 持锁③:cancelled 立即放锁(放弃语义=不再产出,不堵同批文件)', () => {
|
|
@@ -634,30 +725,67 @@ test('touches 持锁④:verifying 持锁拦候选,但 Verifier 派发(veri
|
|
|
634
725
|
const r = core.pickDispatch(mkBoard([vt, cand]), 5, 5, null)
|
|
635
726
|
assert.deepEqual(r.blockedTouches, [{ id: 'cand', conflicts: ['vt'] }]) // verifying 仍持锁
|
|
636
727
|
assert.deepEqual(r.verifs.map(t => t.id), ['vt']) // 只读的 Verifier 不被 touches 拦
|
|
637
|
-
// 驳回会回 in-progress 继续改同一批文件 → 锁在 verifying
|
|
728
|
+
// 驳回会回 in-progress 继续改同一批文件 → 锁在 verifying 期间不能断(放锁点是 resolved,不是 verifying)
|
|
638
729
|
assert.deepEqual(core.holdsFiles(mkBoard([vt])).map(h => h.id), ['vt'])
|
|
639
730
|
})
|
|
640
731
|
|
|
641
|
-
|
|
732
|
+
// ===== touches 锁生命周期·派发级集成(真跑 poolCycle,不是纯函数级断言)=====
|
|
733
|
+
// e2e 场景 T 的本机等价物:场景 T 需要在宿主进程里跑(宿主须先加载新代码,见 task-muwbtee1 上报),
|
|
734
|
+
// 这里用真 poolCycle + 真 resolveApply 把同一条语义链在本机钉死——
|
|
735
|
+
// A(in-progress + claimedBy)持锁 → B(touches 重叠)本轮不派 + waitingForTouches 标注;
|
|
736
|
+
// A 落到 resolved(**不归档**)→ 下一轮 poolCycle 直接派 B,并清掉等待展示态。
|
|
737
|
+
test('poolCycle 集成:A resolved 即放锁 → B 下一轮被派发且 waitingForTouches 被清(e2e 场景 T 等价断言)', async () => {
|
|
738
|
+
const SID = 'session-test-0000-0000-000000000000'
|
|
739
|
+
const board = Object.assign(mkBoard([
|
|
740
|
+
mkTask({ id: 'A', status: 'in-progress', claimedBy: 'run-1', claimedAt: new Date().toISOString(), touches: ['e2e-lock/x.txt'] }),
|
|
741
|
+
mkTask({ id: 'B', touches: ['e2e-lock/**'], createdAt: '2026-01-02' }),
|
|
742
|
+
]), { maxWorkers: 3 })
|
|
743
|
+
// 真 spawnOneShot:provider 可用 + startContinuable 返回不结算的 run(本用例只看「能不能拿到锁」)
|
|
744
|
+
const ctxOver = { subagents: { list: () => ['p1'], getProvider: () => ({ inheritsParentContext: false }), start: async () => ({ id: 'run-2', dispose() {}, result: new Promise(function () {}) }), startContinuable: async () => ({ childId: 'child-1', messageId: 'msg-1' }) } }
|
|
745
|
+
const { dispatch } = mkDispatch(board, { rootForSession: () => ({ id: SID }) }, ctxOver)
|
|
746
|
+
// 第 1 轮:A 持锁(in-progress + claimedBy)→ B 被拦下并在卡上标注在等谁
|
|
747
|
+
await dispatch.poolCycle(SID)
|
|
748
|
+
const A = board.tasks.find(t => t.id === 'A'), B = board.tasks.find(t => t.id === 'B')
|
|
749
|
+
assert.equal(A.status, 'in-progress')
|
|
750
|
+
assert.equal(B.status, 'pending')
|
|
751
|
+
assert.deepEqual(B.waitingForTouches, ['A'])
|
|
752
|
+
// A 完成:resolveApply 落 resolved,**不归档**(新口径的释放点就在这一步的状态流转)
|
|
753
|
+
core.resolveApply(board, A, 'run-1', 'resolved', 'done', 'worker 文本上报完成')
|
|
754
|
+
assert.equal(A.status, 'resolved')
|
|
755
|
+
// 第 2 轮:状态已流转到完成 → 锁即放 → B 被派发、等待展示态被清(归档与否无关)
|
|
756
|
+
await dispatch.poolCycle(SID)
|
|
757
|
+
assert.notEqual(B.status, 'pending')
|
|
758
|
+
assert.equal(B.waitingForTouches, undefined)
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
test('touches 锁生命周期接线(源码级):holdsFiles 两态口径 + 承接关系注释 + 无 resolved/归档放锁残留', () => {
|
|
642
762
|
const src = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
643
|
-
// ①
|
|
644
|
-
assert.match(src, /var holds = t\.status === 'verifying' \|\|
|
|
645
|
-
// ②
|
|
646
|
-
assert.doesNotMatch(src, /verifying
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
763
|
+
// ① 两态持锁口径写在 holdsFiles 里(verifying 与 in-progress+claimedBy;resolved 已移出持锁集合)
|
|
764
|
+
assert.match(src, /var holds = t\.status === 'verifying' \|\| \(t\.status === 'in-progress' && !!t\.claimedBy\)/)
|
|
765
|
+
// ② 旧「锁持到归档」三态口径已退役(verifying||resolved 的耦合判定与「持锁三态」标题都不再存在)
|
|
766
|
+
assert.doesNotMatch(src, /t\.status === 'verifying' \|\| t\.status === 'resolved'/)
|
|
767
|
+
assert.doesNotMatch(src, /持锁三态/)
|
|
768
|
+
// ③ 新口径 + 决策来源日期(用户指令)落款在注释里,防口径漂移
|
|
769
|
+
assert.match(src, /状态流转到已完成(resolved)即放锁/)
|
|
770
|
+
assert.match(src, /用户 2026-10-06 裁决/)
|
|
771
|
+
// ④ 承接关系(主窗口即时门禁纪律 + 史诗 post-hook)写进注释——窗口期不再靠长持锁兜底
|
|
772
|
+
assert.match(src, /主窗口即时门禁纪律/)
|
|
773
|
+
assert.match(src, /史诗 post-hook/)
|
|
774
|
+
// ⑤ 派发层不再有「resolved 时放锁」的动作调用(旧实现 releaseTouchesOnly 不存在);
|
|
775
|
+
// 并写明锁是随状态现算的派生量、没有显式放锁点(resolved 由下一轮 tick 自然放行)
|
|
650
776
|
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
651
777
|
assert.doesNotMatch(dsp, /releaseTouchesOnly/)
|
|
778
|
+
assert.match(dsp, /resolved\/cancelled\/archived 即放/)
|
|
652
779
|
})
|
|
653
780
|
|
|
654
|
-
test('README 双份同步记录 touches
|
|
781
|
+
test('README 双份同步记录 touches 锁随工作态口径(状态流转到 resolved 即释放)', () => {
|
|
655
782
|
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
656
783
|
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
657
784
|
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
658
|
-
for (const s of ['
|
|
785
|
+
for (const s of ['锁随工作态', 'in-progress', 'verifying', 'resolved', 'cancelled', '归档']) {
|
|
659
786
|
assert.ok(pkg.includes(s), 'README 应记录 touches 锁口径:' + s)
|
|
660
787
|
}
|
|
788
|
+
assert.ok(!pkg.includes('锁持到归档'), 'README 不应再记录「锁持到归档」旧口径')
|
|
661
789
|
})
|
|
662
790
|
|
|
663
791
|
// ===== 派发决策(touches 拦截)=====
|
|
@@ -675,7 +803,8 @@ test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 b
|
|
|
675
803
|
|
|
676
804
|
test('pickDispatch: verifying 持有文件锁(锁未断),但 verifier 派发(verifs)不受 touches 影响', () => {
|
|
677
805
|
const tasks = [
|
|
678
|
-
//
|
|
806
|
+
// verifying 仍持锁(驳回会回 in-progress 继续改同一批文件;放锁点是状态流转到 resolved,
|
|
807
|
+
// 不是 in-progress→verifying——用户 2026-10-06 裁决)
|
|
679
808
|
mkTask({ id: 'vt', status: 'verifying', pipeline: 'full', claimedBy: 'run-1', touches: ['src/**'] }),
|
|
680
809
|
mkTask({ id: 'w1', touches: ['src/a.js'] }),
|
|
681
810
|
]
|
|
@@ -710,7 +839,7 @@ test('pickDispatch: frozen/dependsOn/escalation 优先级不变(touches 拦截
|
|
|
710
839
|
mkTask({ id: 'holder', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
|
|
711
840
|
mkTask({ id: 'fz', frozen: true, touches: ['src/a.js'] }), // 冻结 → 既有语义直接排除,不记 blockedTouches
|
|
712
841
|
// dep 的 touches 走 lib/:只验「依赖未满足被既有语义排除」,不与 clash 抢同一把锁
|
|
713
|
-
// (holder
|
|
842
|
+
// (holder 落到 resolved 后 dep 的依赖即满足、会被派发并拿下 src/a.js,抢锁会掩盖本条想验的语义)
|
|
714
843
|
mkTask({ id: 'dep', dependsOn: ['holder'], touches: ['lib/a.js'] }),
|
|
715
844
|
mkTask({ id: 'esc', escalation: { question: 'q' }, touches: ['src/a.js'] }),// 待裁决 → 排除
|
|
716
845
|
mkTask({ id: 'clash', touches: ['src/a.js'] }), // 唯一被 touches 拦下的
|
|
@@ -718,15 +847,20 @@ test('pickDispatch: frozen/dependsOn/escalation 优先级不变(touches 拦截
|
|
|
718
847
|
const r = core.pickDispatch(mkBoard(tasks), 5, 0, null)
|
|
719
848
|
assert.deepEqual(r.pendings, [])
|
|
720
849
|
assert.deepEqual(r.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }])
|
|
721
|
-
//
|
|
850
|
+
// 锁释放点(用户 2026-10-06 裁决):verifying 仍持锁(驳回会回 in-progress 继续改同一批文件);
|
|
851
|
+
// 状态流转到 resolved 才真释放(resolved/归档都不再持锁),被拦任务下一轮自动恢复可派发
|
|
722
852
|
const b2 = mkBoard(tasks)
|
|
723
853
|
b2.tasks[0].status = 'verifying'
|
|
724
854
|
const r2 = core.pickDispatch(b2, 5, 0, null)
|
|
725
855
|
assert.deepEqual(r2.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }]) // verifying 仍持锁
|
|
726
|
-
b2.tasks[0].status = '
|
|
856
|
+
b2.tasks[0].status = 'resolved'
|
|
727
857
|
const r3 = core.pickDispatch(b2, 5, 0, null)
|
|
728
858
|
assert.ok(r3.pendings.map(t => t.id).indexOf('clash') >= 0)
|
|
729
859
|
assert.deepEqual(r3.blockedTouches, [])
|
|
860
|
+
b2.tasks[0].status = 'archived' // 归档幂等:已放锁态下结果逐字不变(归档回归纯收纳动作)
|
|
861
|
+
const r4 = core.pickDispatch(b2, 5, 0, null)
|
|
862
|
+
assert.ok(r4.pendings.map(t => t.id).indexOf('clash') >= 0)
|
|
863
|
+
assert.deepEqual(r4.blockedTouches, [])
|
|
730
864
|
})
|
|
731
865
|
test('normalizeBoard: touches 脏值(非数组)收敛为空数组,缺字段任务照常', () => {
|
|
732
866
|
const legacy = { tasks: [{ id: 'a', touches: 'src/a.js' }, { id: 'b' }, { id: 'c', touches: ['src/c.js'] }] }
|
|
@@ -862,10 +996,10 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
|
|
|
862
996
|
// 幻影板防线(task-muuf0o7a)落地后,create-task 对不完整短 id 直接报错——mock 默认 sid
|
|
863
997
|
// 必须是完整形态(session-xxxx-xxxx-...),否则全量 create-task 用例会被防线拦住。
|
|
864
998
|
const FULL_SID = 'session-test-0000-0000-000000000000'
|
|
865
|
-
function mkRpcHandlers(board, extra) {
|
|
999
|
+
function mkRpcHandlers(board, extra, ctxExtra) {
|
|
866
1000
|
const state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {} }
|
|
867
1001
|
const tools = {} // 工具通道捕获:双通道接线测试经 __tools['task_create'].execute(...) 直调
|
|
868
|
-
const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
|
|
1002
|
+
const ctx = Object.assign({ tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }, ctxExtra || {})
|
|
869
1003
|
const deps = Object.assign({
|
|
870
1004
|
getActorId: () => 'tester', resolveRoot: (x) => x,
|
|
871
1005
|
toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
|
|
@@ -968,10 +1102,13 @@ test('epicPrecheck: 字段有无 + 路径存在性(exists 注入),direct/
|
|
|
968
1102
|
assert.deepEqual(pre.missing.map((m) => m.id), ['k1', 'k3'])
|
|
969
1103
|
assert.match(pre.missing[0].reason, /无 contextFiles/)
|
|
970
1104
|
assert.match(pre.missing[1].reason, /全部不存在/)
|
|
971
|
-
// 锚点 :L 段先剥掉再查存在性('b.mjs:L3-L9' 以 'b.mjs' 查 exists
|
|
1105
|
+
// 锚点 :L 段先剥掉再查存在性('b.mjs:L3-L9' 以 'b.mjs' 查 exists);
|
|
1106
|
+
// 可选「 — 一句用途」后缀同样剥掉(瘦身清单条目写法,task-muvjs392)——存在性是对文件而言的。
|
|
1107
|
+
// some 短路:第一条 false 才会继续查第二条,正好把两种剥法都走一遍。
|
|
972
1108
|
const seen = []
|
|
973
|
-
core.epicPrecheck([mkTask({ id: 'x', parentId: 'ep', context: { files: ['b.mjs:L3-L9'], notes: '' } })], 'ep', (p) => { seen.push(p); return
|
|
974
|
-
assert.deepEqual(seen, ['b.mjs'])
|
|
1109
|
+
const pre2 = core.epicPrecheck([mkTask({ id: 'x', parentId: 'ep', context: { files: ['b.mjs:L3-L9', 'd.mjs:L1-L5 — 状态机主循环'], notes: '' } })], 'ep', (p) => { seen.push(p); return p === 'd.mjs' })
|
|
1110
|
+
assert.deepEqual(seen, ['b.mjs', 'd.mjs']) // 锚点与用途都剥掉,落到纯路径
|
|
1111
|
+
assert.deepEqual(pre2.missing, []) // d.mjs 存在 → 不判缺材料
|
|
975
1112
|
})
|
|
976
1113
|
|
|
977
1114
|
test('epicPrecheckNote: 全部有材料 → 空串(不打扰);有缺失 → N/M 汇总文案(超 5 条折叠)', () => {
|
|
@@ -1132,7 +1269,7 @@ function mkVerifierSettleDispatch(board) {
|
|
|
1132
1269
|
start: async function () { return { id: 'run-v1', result: new Promise(function (res) { resolveRun = res }), dispose: async function () {} } },
|
|
1133
1270
|
},
|
|
1134
1271
|
}
|
|
1135
|
-
var state = { knownSessions: {}, dispatchedEver: {}, badModels: {},
|
|
1272
|
+
var state = { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} }
|
|
1136
1273
|
var dispatch = createDispatch(ctx, state, {
|
|
1137
1274
|
rt: async function () { return boardRef.b }, wt: async function () {}, mutateLocked: async function (sid, fn) { return fn(boardRef.b) },
|
|
1138
1275
|
kickCycle: function () {}, rootForSession: function () { return { id: FULL_SID } }, sessionCwd: function () { return '' },
|
|
@@ -1240,7 +1377,7 @@ test('驳回全量带回接线(源码级):三条驳回路径都调 pushRej
|
|
|
1240
1377
|
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
1241
1378
|
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
1242
1379
|
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\.
|
|
1380
|
+
assert.match(dsp, /pushRejection\(t, vsecs\.verifySummary, vsecs\.checks, t\.verification\.at, String\(rec\.id\)\)/)
|
|
1244
1381
|
assert.equal((hostSrc().match(/pushRejection\(t, /g) || []).length, 4) // 三条路径 + Verifier 文本结算
|
|
1245
1382
|
assert.match(coreSrc, /export function pushRejection/)
|
|
1246
1383
|
assert.match(coreSrc, /kind: 'rejection'/)
|
|
@@ -1280,7 +1417,7 @@ test('aggregateUsageSummary: 空任务/无 usage 任务 → 全零 + 空 Top', (
|
|
|
1280
1417
|
assert.equal(s.total, 0); assert.equal(s.input, 0); assert.equal(s.output, 0); assert.equal(s.cacheRead, 0)
|
|
1281
1418
|
assert.deepEqual(s.byModel, {}); assert.deepEqual(s.topTasks, [])
|
|
1282
1419
|
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: [] })
|
|
1420
|
+
assert.deepEqual(aggregateUsageSummary(undefined), { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byModelEff: {}, byDay: {}, topTasks: [] })
|
|
1284
1421
|
})
|
|
1285
1422
|
|
|
1286
1423
|
test('aggregateUsageSummary: 总量/输入输出缓存拆分累加 + 按模型小计合并', () => {
|
|
@@ -1397,11 +1534,243 @@ test('aggregateUsageSummary: effective 字段与 effectiveTokens 导出一致(
|
|
|
1397
1534
|
assert.equal(s.effective, effectiveTokens({ input: 3, output: 4, cacheRead: 99, cacheWrite: 5 }))
|
|
1398
1535
|
})
|
|
1399
1536
|
|
|
1537
|
+
// ===== 统计范围(range)过滤:范围过滤在 host 做(run 级数据 t.runs[i].usage + t.runs[i].at 只在 host)=====
|
|
1538
|
+
// 测试侧独立构造 run 留账(含 at 时间戳 + 模型名),不复用实现内部函数——防「实现自证」。
|
|
1539
|
+
// 数据源位置是硬口径(task-muwq9u04 的 bug 根因):run 记录数组挂在**任务级 t.runs**,
|
|
1540
|
+
// t.usage.runs 只是「结算次数」计数(number)——把计数当数组读会恒得空数组,范围过滤静默失效。
|
|
1541
|
+
const mkRun = (at, model, u) => ({ role: 'worker', id: 'r-' + at + '-' + model, at, model, outcome: 'ok', usage: u })
|
|
1542
|
+
const U = (input, output, cacheRead, cacheWrite) => ({ input, output, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite })
|
|
1543
|
+
|
|
1544
|
+
test('aggregateUsageSummary 范围过滤:逐 run 裁切(任务级 t.runs[])+ 两端边界日 + 无 at 兜底', () => {
|
|
1545
|
+
const t1 = mkTask({
|
|
1546
|
+
id: 'a', title: 'A',
|
|
1547
|
+
// 四条 run 合计与任务级 usage 五分量一致(总 700),便于核对「范围内只算入选 run」
|
|
1548
|
+
runs: [
|
|
1549
|
+
mkRun(localIso(2026, 9, 30, 23, 0), 'm-old', U(1, 1, 8, 0)), // 范围外(09-30)
|
|
1550
|
+
mkRun(localIso(2026, 10, 1, 8, 0), 'm-mid', U(3, 4, 100, 5)), // 范围内(10-01)
|
|
1551
|
+
mkRun(localIso(2026, 10, 5, 9, 0), 'm-new', U(6, 15, 152, 5)), // 范围外(10-05)
|
|
1552
|
+
mkRun('', 'm-noat', U(100, 100, 100, 100)) // 无 at 的老 run:裁不了 → 只在无范围时计入
|
|
1553
|
+
],
|
|
1554
|
+
usage: { total: 700, input: 110, output: 120, cacheRead: 360, cacheWrite: 110, runs: 4, models: { 'm-x': 700 }, updatedAt: localIso(2026, 10, 2, 10, 0) }
|
|
1555
|
+
})
|
|
1556
|
+
const s = aggregateUsageSummary([t1], { from: '2026-10-01', to: '2026-10-03' })
|
|
1557
|
+
assert.equal(s.total, 112) // 只有 10-01 那条计入(3+4+100+5)
|
|
1558
|
+
assert.equal(s.input, 3); assert.equal(s.output, 4)
|
|
1559
|
+
assert.equal(s.cacheRead, 100); assert.equal(s.cacheWrite, 5)
|
|
1560
|
+
assert.equal(s.effective, 12) // 有效 = 输入+输出+缓存写,不含缓存读
|
|
1561
|
+
assert.deepEqual(s.byModel, { 'm-mid': 112 }) // 模型分布随范围收窄(run 级重算)
|
|
1562
|
+
assert.deepEqual(s.byModelEff, { 'm-mid': 12 }) // 模型有效分摊同源(3+4+5)
|
|
1563
|
+
assert.deepEqual(s.topTasks.map(x => x.id), ['a'])
|
|
1564
|
+
assert.equal(s.topTasks[0].total, 112); assert.equal(s.topTasks[0].effective, 12)
|
|
1565
|
+
assert.equal(s.topTasks[0].runs, 1) // 「N 次 run」在范围下报入选 run 数(旧版此处置 0)
|
|
1566
|
+
// 边界包含性:只选范围首日 / 只选范围末日,两端都必须命中(闭区间口径)
|
|
1567
|
+
assert.equal(aggregateUsageSummary([t1], { from: '2026-10-01', to: '2026-10-01' }).total, 112)
|
|
1568
|
+
assert.equal(aggregateUsageSummary([t1], { from: '2026-10-05', to: '2026-10-05' }).total, 178)
|
|
1569
|
+
assert.equal(aggregateUsageSummary([t1], { from: '2026-10-05', to: '2026-10-05' }).effective, 26) // 6+15+5
|
|
1570
|
+
// 只给一端:from 不设上限 / to 不设下限
|
|
1571
|
+
assert.equal(aggregateUsageSummary([t1], { from: '2026-10-05' }).total, 178)
|
|
1572
|
+
assert.equal(aggregateUsageSummary([t1], { to: '2026-09-30' }).total, 10)
|
|
1573
|
+
// 范围内一条 run 都不入选(含「无 at 的老 run」)→ 该任务零贡献,不进 total 也不进 Top8
|
|
1574
|
+
const s0 = aggregateUsageSummary([t1], { from: '2020-01-01', to: '2020-01-02' })
|
|
1575
|
+
assert.equal(s0.total, 0); assert.deepEqual(s0.topTasks, []); assert.deepEqual(s0.byModel, {}); assert.deepEqual(s0.byModelEff, {})
|
|
1576
|
+
assert.deepEqual(s0.byDay, {}) // byDay 同步裁到范围内(范围外日不入)
|
|
1577
|
+
})
|
|
1578
|
+
|
|
1579
|
+
test('aggregateUsageSummary 范围过滤:run 未记模型名时按任务级占比摊(模型键不消失、Σ 与入选总量对齐)', () => {
|
|
1580
|
+
// 真实数据形状:r.model 常是空串(派发未覆盖模型时模型名只落在日志里),而 t.usage.models 用的是
|
|
1581
|
+
// 日志记录的名字。若范围路径只认 r.model,这部分用量会凭空丢掉模型键——本机实测某个模型的整键
|
|
1582
|
+
// (`deepseek-flash`)在切范围后消失,且「按模型分布」加起来 ≠ 累计。
|
|
1583
|
+
const t = mkTask({
|
|
1584
|
+
id: 'a', title: 'A',
|
|
1585
|
+
runs: [
|
|
1586
|
+
mkRun(localIso(2026, 10, 5, 9, 0), 'm-known', U(10, 10, 0, 0)), // 合计 20:run 上有模型名
|
|
1587
|
+
mkRun(localIso(2026, 10, 5, 10, 0), '', U(30, 30, 40, 0)) // 合计 100:无模型名(派发未覆盖)
|
|
1588
|
+
],
|
|
1589
|
+
usage: { total: 120, input: 40, output: 40, cacheRead: 40, cacheWrite: 0, runs: 2, models: { 'm-known': 60, 'm-log': 60 }, updatedAt: localIso(2026, 10, 5, 10, 30) }
|
|
1590
|
+
})
|
|
1591
|
+
const s = aggregateUsageSummary([t], { from: '2026-10-05', to: '2026-10-05' })
|
|
1592
|
+
assert.equal(s.total, 120); assert.equal(s.effective, 80) // 有效 = 40+40+0(缓存读 40 不计)
|
|
1593
|
+
assert.equal(Object.keys(s.byModel).length, 2) // 两个模型键都在(无名的按占比摊,不消失)
|
|
1594
|
+
assert.equal(s.byModel['m-known'], 70) // 精确 20 + 摊派 60*(100/120)=50
|
|
1595
|
+
assert.equal(s.byModel['m-log'], 50)
|
|
1596
|
+
const sum = Object.keys(s.byModel).reduce((n, k) => n + s.byModel[k], 0)
|
|
1597
|
+
assert.equal(sum, s.total) // Σ byModel 与本次入选 total 对齐
|
|
1598
|
+
assert.equal(s.byModelEff['m-known'], 20 + Math.round(60 * (60 / 120))) // 有效:精确 20 + 摊派 30
|
|
1599
|
+
assert.equal(s.byModelEff['m-log'], 30)
|
|
1600
|
+
const sumE = Object.keys(s.byModelEff).reduce((n, k) => n + s.byModelEff[k], 0)
|
|
1601
|
+
assert.equal(sumE, s.effective) // Σ byModelEff 与入选有效对齐
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
test('aggregateUsageSummary 范围过滤:topTasks 随范围收窄(回归:数据源错位时 Top8 与无范围逐字相同)', () => {
|
|
1605
|
+
// 活体实证的回归形状(task-muwq9u04):任务最后一次结算落在范围内(updatedAt=10-02),
|
|
1606
|
+
// 但大头花在范围外(09-30)。修复前(把 t.usage.runs 计数当数组读 → 恒空数组 → 整任务退化成
|
|
1607
|
+
// updatedAt 单日近似)该任务整笔 150 计入、「Top8 主数字与无范围逐字相同」——正是用户看到的现象。
|
|
1608
|
+
const t = mkTask({
|
|
1609
|
+
id: 'a', title: 'A',
|
|
1610
|
+
runs: [
|
|
1611
|
+
mkRun(localIso(2026, 9, 30, 9, 0), 'm-old', U(30, 20, 50, 0)), // 合计 100(有效 50):范围外
|
|
1612
|
+
mkRun(localIso(2026, 10, 2, 9, 0), 'm-new', U(10, 10, 30, 0)) // 合计 50(有效 20):范围内
|
|
1613
|
+
],
|
|
1614
|
+
usage: { total: 150, input: 40, output: 30, cacheRead: 80, cacheWrite: 0, runs: 2, models: { 'm-x': 150 }, updatedAt: localIso(2026, 10, 2, 9, 30) }
|
|
1615
|
+
})
|
|
1616
|
+
const all = aggregateUsageSummary([t])
|
|
1617
|
+
assert.equal(all.total, 150) // 无范围:整笔
|
|
1618
|
+
assert.equal(all.topTasks[0].total, 150); assert.equal(all.topTasks[0].effective, 70)
|
|
1619
|
+
const r = aggregateUsageSummary([t], { from: '2026-10-02', to: '2026-10-02' })
|
|
1620
|
+
assert.equal(r.total, 50) // 范围内只算 10-02 那条
|
|
1621
|
+
assert.deepEqual(r.topTasks.map(x => x.total), [50])
|
|
1622
|
+
assert.deepEqual(r.topTasks.map(x => x.effective), [20])
|
|
1623
|
+
assert.equal(r.topTasks[0].runs, 1)
|
|
1624
|
+
assert.notDeepEqual(r.topTasks.map(x => x.total), all.topTasks.map(x => x.total)) // 逐字相同即回归(数据源错位)
|
|
1625
|
+
assert.deepEqual(r.byModel, { 'm-new': 50 }) // 模型分布同口径收窄(不是 {'m-x':150})
|
|
1626
|
+
assert.deepEqual(r.byModelEff, { 'm-new': 20 })
|
|
1627
|
+
})
|
|
1628
|
+
|
|
1629
|
+
test('aggregateUsageSummary 范围过滤:无 range = 现状逐字不变(parity,含无 at 的 run 照旧全量计入)', () => {
|
|
1630
|
+
const tasks = [
|
|
1631
|
+
mkTask({
|
|
1632
|
+
id: 'a', usage: {
|
|
1633
|
+
total: 300, input: 10, output: 20, cacheRead: 260, cacheWrite: 10, runs: 2, models: { 'm-x': 300 },
|
|
1634
|
+
updatedAt: localIso(2026, 10, 2, 10, 0)
|
|
1635
|
+
},
|
|
1636
|
+
runs: [mkRun(localIso(2026, 9, 30, 23, 0), 'm-old', U(1, 1, 8, 0)), mkRun('', 'm-noat', U(100, 100, 100, 100))]
|
|
1637
|
+
}),
|
|
1638
|
+
mkTask({ id: 'legacy', usage: { total: 500, input: 0, output: 0, cacheRead: 500, cacheWrite: 0, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 11, 0) } }),
|
|
1639
|
+
mkTask({ id: 'nodate', usage: { total: 9, input: 9, output: 0, cacheRead: 0, cacheWrite: 0, models: { 'm-z': 9 } } })
|
|
1640
|
+
]
|
|
1641
|
+
const base = aggregateUsageSummary(tasks)
|
|
1642
|
+
assert.deepEqual(aggregateUsageSummary(tasks, { from: '', to: '' }), base) // 空范围与不传等价(客户端空范围即此形态)
|
|
1643
|
+
assert.deepEqual(aggregateUsageSummary(tasks, {}), base)
|
|
1644
|
+
assert.deepEqual(aggregateUsageSummary(tasks, null), base) // 老调用形态(只传 tasks)逐字不变
|
|
1645
|
+
assert.equal(base.total, 809) // 300 + 500 + 9(run 留账不参与无范围口径)
|
|
1646
|
+
assert.deepEqual(base.byModel, { 'm-x': 300, 'm-z': 9 })
|
|
1647
|
+
assert.deepEqual(base.byModelEff, { 'm-x': 40, 'm-z': 9 }) // 无范围:任务级按「有效/总量」摊派
|
|
1648
|
+
assert.equal(base.effective, 49) // 40(a) + 0(legacy 无分量) + 9
|
|
1649
|
+
// 无 runs 留账的老任务:无范围时照旧全量计入(parity 的关键一条)
|
|
1650
|
+
assert.ok(base.topTasks.some(x => x.id === 'legacy'))
|
|
1651
|
+
})
|
|
1652
|
+
|
|
1653
|
+
test('aggregateUsageSummary 范围过滤:无 run 级留账的老任务按 updatedAt 本地日判范围(近似口径,无日可判则不计入)', () => {
|
|
1654
|
+
const legacy = mkTask({ id: 'old', usage: { total: 500, input: 0, output: 0, cacheRead: 500, cacheWrite: 0, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 10, 30) } })
|
|
1655
|
+
const keep = aggregateUsageSummary([legacy], { from: '2026-10-02', to: '2026-10-02' })
|
|
1656
|
+
assert.equal(keep.total, 500) // updatedAt 落在范围内 → 计入(近似归日)
|
|
1657
|
+
assert.equal(keep.effective, 0) // 老任务只有总量:有效消耗仍按分量算(全 0)
|
|
1658
|
+
assert.equal(aggregateUsageSummary([legacy], { from: '2026-10-03', to: '2026-10-03' }).total, 0) // 范围外 → 剔除
|
|
1659
|
+
const noDate = mkTask({ id: 'nodate', usage: { total: 9, models: {} } })
|
|
1660
|
+
assert.equal(aggregateUsageSummary([noDate], { from: '2026-10-01', to: '2026-10-31' }).total, 0) // 无日可判 → 宁可漏不错
|
|
1661
|
+
assert.equal(aggregateUsageSummary([noDate]).total, 9) // 无范围时照旧计入(parity)
|
|
1662
|
+
// runs 条目存在但**都没落 usage**(老 run / 未结算)=同样没有 run 级留账可裁 → 照样走 updatedAt 近似,
|
|
1663
|
+
// 不能因为「有 runs 数组」就判该任务对范围零贡献(那会把老卡的真实消耗整块吞掉)
|
|
1664
|
+
const bare = mkTask({
|
|
1665
|
+
id: 'bare', runs: [{ role: 'worker', id: 'r1', at: localIso(2026, 10, 2, 9, 0), outcome: 'completed' }],
|
|
1666
|
+
usage: { total: 77, input: 7, output: 7, cacheRead: 63, cacheWrite: 0, runs: 1, models: { 'm-bare': 77 }, updatedAt: localIso(2026, 10, 2, 9, 0) }
|
|
1667
|
+
})
|
|
1668
|
+
const bareKept = aggregateUsageSummary([bare], { from: '2026-10-02', to: '2026-10-02' })
|
|
1669
|
+
assert.equal(bareKept.total, 77)
|
|
1670
|
+
assert.deepEqual(bareKept.topTasks.map(x => x.id), ['bare'])
|
|
1671
|
+
assert.deepEqual(bareKept.byModelEff, { 'm-bare': 14 }) // 有效 7+7+0=14,任务级按占比摊派(77/77)
|
|
1672
|
+
assert.equal(aggregateUsageSummary([bare], { from: '2026-10-03', to: '2026-10-03' }).total, 0)
|
|
1673
|
+
})
|
|
1674
|
+
|
|
1675
|
+
test('aggregateUsageSummary 范围过滤:byDay 同步裁到范围内(范围外日不入 byDay)', () => {
|
|
1676
|
+
const t1 = mkTask({
|
|
1677
|
+
id: 'a', usage: {
|
|
1678
|
+
total: 100, input: 0, output: 0, cacheRead: 100, cacheWrite: 0, runs: 2, models: {},
|
|
1679
|
+
updatedAt: localIso(2026, 10, 3, 10, 0),
|
|
1680
|
+
byDay: { '2026-09-30': { t: 40, e: 40 }, '2026-10-03': { t: 60, e: 60 } }
|
|
1681
|
+
}
|
|
1682
|
+
})
|
|
1683
|
+
const s = aggregateUsageSummary([t1], { from: '2026-10-01', to: '2026-10-31' })
|
|
1684
|
+
assert.deepEqual(s.byDay, { '2026-10-03': { t: 60, e: 60 } }) // 域外的 09-30 不进 byDay
|
|
1685
|
+
assert.equal(s.total, 100) // 但任务级 total 不被 byDay 裁剪影响(无 runs 留账 → 按 updatedAt 判在范围内)
|
|
1686
|
+
})
|
|
1687
|
+
|
|
1688
|
+
// ===== 统计范围接线(源码级断言):host get-tasks 透传 + client 轮询带范围 + setRange 触发重拉 + UI 文案 =====
|
|
1689
|
+
test('Token 区统计范围接线:get-tasks 接 range 透传 + fetchTasks 带范围 + setRange 触发重拉(源码级断言)', () => {
|
|
1690
|
+
const host = hostSrc()
|
|
1691
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
1692
|
+
// host:get-tasks 把 args.range 透传给聚合(过滤在 host 做——run 级数据只在 host)
|
|
1693
|
+
assert.match(host, /aggregateUsageSummary\(d\.tasks, args && args\.range\)/)
|
|
1694
|
+
assert.match(host, /runs\[i\]\.usage/) // run 级留账(范围过滤的数据源,先于本次改动已在)
|
|
1695
|
+
// client:轮询带上当前统计范围;空范围不传 field(老 host 调用形态逐字不变)
|
|
1696
|
+
assert.match(cli, /var rgSend = activeRange\(\)/)
|
|
1697
|
+
assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : undefined/)
|
|
1698
|
+
assert.match(cli, /rpc\('get-tasks', rpcArgs\)/)
|
|
1699
|
+
// 范围守卫:响应回来时范围已变则丢弃旧范围的聚合,不覆盖新范围的值
|
|
1700
|
+
assert.match(cli, /if \(rgKey === \(rgNow\.from \+ '~' \+ rgNow\.to\)\) state\.usageSummary = \(d && d\.usageSummary\) \|\| null/)
|
|
1701
|
+
// RangeFilter:setRange 触发重拉(报告/总览本地现算,Token 区必须回 host 重算)
|
|
1702
|
+
assert.match(cli, /state\.dateRange = \{ from: from, to: to \}/)
|
|
1703
|
+
assert.match(cli, /if \(typeof fetchTasks === 'function'\) fetchTasks\(\)/)
|
|
1704
|
+
// UI:范围激活时 Token 区标题带标记 + 范围作用域 caption(今日/近 7 天固定口径写明)
|
|
1705
|
+
assert.match(cli, /'范围内: ' \+ rangeLabel\(\)/)
|
|
1706
|
+
assert.match(cli, /统计范围作用于按模型分布 \/ 任务消耗 Top 8 \/ 累计三分量/)
|
|
1707
|
+
assert.match(cli, /今日与「近 7 天」为固定口径,不随范围变化/)
|
|
1708
|
+
})
|
|
1709
|
+
|
|
1710
|
+
// ===== Token 区三连修(task-muwq9u04):范围过滤数据源 / 选范围即渲染 / 主数字口径统一 =====
|
|
1711
|
+
test('Token 区①:范围过滤读**任务级 t.runs[]**(run 级留账真实位置),不再误读 usage.runs 计数(源码级断言)', () => {
|
|
1712
|
+
const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
|
|
1713
|
+
// 数据源必须取自 t.runs(数组,条目带 at/model/usage);t.usage.runs 只是「结算次数」计数(number)
|
|
1714
|
+
assert.match(usageSrc, /var trs = Array\.isArray\(t\.runs\) \? t\.runs : \[\]/)
|
|
1715
|
+
assert.equal(/Array\.isArray\(uRaw\.runs\)/.test(usageSrc), false) // 回归根因:把计数当数组读 → 恒空 → 过滤静默失效
|
|
1716
|
+
assert.match(usageSrc, /if \(!dayInRange\(dayKeyOf\(r && r\.at\), range\)\) continue/)
|
|
1717
|
+
// topTasks 与 total 同一份入选 run 数据(同一过滤口径,不再各算一遍)
|
|
1718
|
+
assert.match(usageSrc, /models\[rm\] = \(models\[rm\] \|\| 0\) \+ ru\.total/)
|
|
1719
|
+
assert.match(usageSrc, /modelsE\[rm\] = \(modelsE\[rm\] \|\| 0\) \+ effectiveTokens\(ru\)/)
|
|
1720
|
+
assert.match(usageSrc, /s\.topTasks\.push\(\{ id: t\.id, title: t\.title, total: uTot, effective: uEff/)
|
|
1721
|
+
})
|
|
1722
|
+
|
|
1723
|
+
test('Token 区②:usageSummary 变化纳入 notify 触发(选范围即渲染,JSON 串比对而非引用比)', () => {
|
|
1724
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
1725
|
+
assert.match(cli, /var usageJsonPrev = JSON\.stringify\(state\.usageSummary \|\| null\)/) // 赋值前快照
|
|
1726
|
+
assert.match(cli, /var usageDelta = !tasksChanged && JSON\.stringify\(state\.usageSummary \|\| null\) !== usageJsonPrev/)
|
|
1727
|
+
assert.match(cli, /if \(cfgDelta \|\| usageDelta\) notify\(\)/)
|
|
1728
|
+
// 顺序:快照 → 范围守卫下赋值 → 比对(先赋值后快照会把 delta 恒置 false)
|
|
1729
|
+
const iSnap = cli.indexOf('var usageJsonPrev =')
|
|
1730
|
+
const iAssign = cli.indexOf('state.usageSummary = (d && d.usageSummary) || null')
|
|
1731
|
+
const iDelta = cli.indexOf('var usageDelta =')
|
|
1732
|
+
const iNotify = cli.indexOf('if (cfgDelta || usageDelta) notify()')
|
|
1733
|
+
assert.ok(iSnap >= 0 && iAssign > iSnap && iDelta > iAssign && iNotify > iDelta, 'usageSummary 变化检测顺序应为 快照→赋值→比对→notify')
|
|
1734
|
+
// 反向断言:不得用引用比较(host 每轮都返回新对象 → 引用比恒真 → 3s 轮询每轮重渲染,tasksHash 节约作废)
|
|
1735
|
+
assert.equal(/state\.usageSummary !== prevUsage/.test(cli), false)
|
|
1736
|
+
})
|
|
1737
|
+
|
|
1738
|
+
test('Token 区③:Top8 与模型分布主数字取有效消耗(合计进 title),caption 写明口径统一(源码级断言)', () => {
|
|
1739
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
1740
|
+
// 模型分布:主数字取 host 的 byModelEff(有效分摊),缺字段退化合计并标 ~
|
|
1741
|
+
assert.match(cli, /var ee = \(u\.byModelEff && typeof u\.byModelEff\[m\] === 'number'\) \? u\.byModelEff\[m\] : null/)
|
|
1742
|
+
assert.match(cli, /eff: \(ee === null \? tt : ee\), approx: ee === null/)
|
|
1743
|
+
assert.match(cli, /value: m\.eff, max: maxM/)
|
|
1744
|
+
assert.match(cli, /含缓存读合计 ' \+ String\(m\.total\) \+ ' tokens'/)
|
|
1745
|
+
// Top8:主数字取 topTasks[].effective(老卡无分量才退化合计)
|
|
1746
|
+
assert.match(cli, /var ee = \(typeof x\.effective === 'number'\) \? x\.effective : null/)
|
|
1747
|
+
assert.match(cli, /eff: \(ee === null \? x\.total : ee\)/)
|
|
1748
|
+
assert.match(cli, /value: x\.eff, max: maxT/)
|
|
1749
|
+
assert.match(cli, /含缓存读合计 ' \+ String\(x\.total\)/)
|
|
1750
|
+
// 排序按显示口径(有效)降序:条形长度与行序一致
|
|
1751
|
+
assert.match(cli, /\}\)\.sort\(function \(a, b\) \{ return b\.eff - a\.eff \}\)/)
|
|
1752
|
+
// 标题与 caption 写明口径统一(主数字均为有效消耗)
|
|
1753
|
+
assert.match(cli, /'按模型分布(有效消耗)'/)
|
|
1754
|
+
assert.match(cli, /'任务消耗 Top 8(有效消耗)'/)
|
|
1755
|
+
assert.match(cli, /主数字(模型分布 \/ Top 8)与「今日」「近 7 天」均为有效消耗口径/)
|
|
1756
|
+
})
|
|
1757
|
+
|
|
1758
|
+
test('Token 区③:byModelEff 与累计有效自洽(模型有效分摊不出「模型合计 > 累计有效」的矛盾)', () => {
|
|
1759
|
+
const a = mkTask({ id: 'a', usage: { input: 10, output: 5, cacheRead: 900, cacheWrite: 5, total: 920, runs: 1, models: { 'm-1': 920 } } })
|
|
1760
|
+
const b = mkTask({ id: 'b', usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1, total: 30, runs: 1, models: { 'm-1': 10, 'm-2': 20 } } })
|
|
1761
|
+
const s = aggregateUsageSummary([a, b])
|
|
1762
|
+
assert.equal(s.effective, 23) // (10+5+5) + (1+1+1)
|
|
1763
|
+
const sum = Object.keys(s.byModelEff).reduce((n, k) => n + s.byModelEff[k], 0)
|
|
1764
|
+
assert.ok(Math.abs(sum - s.effective) <= Object.keys(s.byModelEff).length, 'Σ byModelEff 应与 effective 自洽(仅四舍五入误差):' + sum)
|
|
1765
|
+
assert.deepEqual(s.byModelEff, { 'm-1': 21, 'm-2': 2 }) // a 的 20 全归 m-1;b 的 3 按 10:20 摊成 1:2 → m-1 合计 21
|
|
1766
|
+
assert.equal(s.byModel['m-1'], 930)
|
|
1767
|
+
})
|
|
1768
|
+
|
|
1400
1769
|
test('README 双份同步记录 Token 口径(有效消耗 / 近 7 天 / 缓存读单列 / 派发口径边界)', () => {
|
|
1401
1770
|
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
1402
1771
|
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
1403
1772
|
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
1404
|
-
for (const s of ['今日有效消耗', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账']) {
|
|
1773
|
+
for (const s of ['今日有效消耗', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账', '统计范围', '主数字口径统一', 't.runs[]']) {
|
|
1405
1774
|
assert.ok(pkg.includes(s), 'README 应记录口径:' + s)
|
|
1406
1775
|
}
|
|
1407
1776
|
})
|
|
@@ -1410,7 +1779,7 @@ test('Token run 级留账:accumulateRunUsage 把本次用量写回对应 t.run
|
|
|
1410
1779
|
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
1411
1780
|
// 五分量原样写回 runs 条目:任何维度(按天/按模型/按阶段)都能从 runs 精确重建,不必回头猜
|
|
1412
1781
|
assert.match(dsp, /if \(Array\.isArray\(t\.runs\)\)/)
|
|
1413
|
-
assert.match(dsp, /if \(String\(t\.runs\[ri\] && t\.runs\[ri\]\.id\) === String\(rec\.
|
|
1782
|
+
assert.match(dsp, /if \(String\(t\.runs\[ri\] && t\.runs\[ri\]\.id\) === String\(rec\.id\)\)/)
|
|
1414
1783
|
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
1784
|
assert.match(dsp, /break/) // 命中即停(同一 runId 只记一次)
|
|
1416
1785
|
// 倒序查找 + 找不到就跳过:runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,正常必存在
|
|
@@ -1463,7 +1832,7 @@ test('Token 消耗接线:settleRun 结算累加 + 按模型小计 + get-tasks
|
|
|
1463
1832
|
assert.match(host, /t\.usage\.models\[mk\] = \(t\.usage\.models\[mk\] \|\| 0\) \+ u\.total/) // 按模型小计累加
|
|
1464
1833
|
assert.match(host, /rec\.model \|\| u\.model/) // 模型 key 来源:派发覆盖 > 日志记录
|
|
1465
1834
|
assert.match(host, /inputTokens/) // 字段名与真实 v4 日志一致
|
|
1466
|
-
assert.match(host, /d\.usageSummary = aggregateUsageSummary\(d\.tasks\)/) // get-tasks
|
|
1835
|
+
assert.match(host, /d\.usageSummary = aggregateUsageSummary\(d\.tasks, args && args\.range\)/) // get-tasks 现算聚合(带统计范围)
|
|
1467
1836
|
assert.match(cli, /React\.createElement\(TokenUsage, \{ usage: state\.usageSummary \}\)/) // 仪表盘插入消耗区
|
|
1468
1837
|
assert.match(cli, /state\.usageSummary = \(d && d\.usageSummary\) \|\| null/) // 客户端取数
|
|
1469
1838
|
assert.match(cli, /'⛁ ' \+ fmtTokens\(t\.usage\.total\)/) // 进行中/已完成卡片显示本任务累计
|
|
@@ -1497,7 +1866,8 @@ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表
|
|
|
1497
1866
|
assert.match(cli, /k\.slice\(5\) \+ ':有效 ' \+ String\(v\)/) // 条形 title:MM-DD:有效 N tok
|
|
1498
1867
|
assert.match(cli, /含缓存读共 ' \+ String\(c\.t\) \+ ' tok'/) // 条形 title 补总量对照
|
|
1499
1868
|
assert.match(cli, /口径:仅看板派发的 Worker\/Verifier run 消耗,不含主窗口对话/) // 口径边界明示(不含主窗口)
|
|
1500
|
-
assert.match(cli,
|
|
1869
|
+
assert.match(cli, /主数字(模型分布 \/ Top 8)与「今日」「近 7 天」均为有效消耗口径/) // 主数字口径统一(task-muwq9u04)
|
|
1870
|
+
assert.match(cli, /其中缓存读 ' \+ String\(x\.cacheRead \|\| 0\)/) // Top8 title 补 缓存读 拆分(合计/有效进 title)
|
|
1501
1871
|
assert.match(cli, /background: isToday \? C\.brand : C\.nested/) // 今天高亮 brand、其余浅底
|
|
1502
1872
|
assert.match(cli, /hasDayData \? React\.createElement/) // 7 天全空不渲染该区
|
|
1503
1873
|
assert.match(cli, /'(近似:老日账只有总量)'/) // e 不可知 → 标 ~ 近似,不冒充有效值
|
|
@@ -1656,13 +2026,13 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
|
|
|
1656
2026
|
assert.match(host, /args\.key === 'feedbackEnabled'/)
|
|
1657
2027
|
assert.match(host, /feedbackCache\[sid\] = d\.feedbackEnabled/)
|
|
1658
2028
|
assert.match(host, /feedbackCache\[sid\] = nd\.feedbackEnabled !== false/)
|
|
1659
|
-
// 软召回:Worker prompt 与 Team
|
|
1660
|
-
assert.match(host, /buildWorkerPrompt\(t,
|
|
2029
|
+
// 软召回:Worker prompt 与 Team 提示词都以开关为条件拼接(pack=瘦身清单本体,随首条 prompt 注入)
|
|
2030
|
+
assert.match(host, /buildWorkerPrompt\(t, pack, cfg\(dsnap\)\.feedbackEnabled\)/)
|
|
1661
2031
|
assert.match(host, /feedbackOn\(String\(agent\.id\)\) \? '\\n' \+ LESSON_RECALL_HINT/)
|
|
1662
2032
|
assert.match(coreSrc, /if \(feedbackEnabled !== false\) p \+= '\\n\\n' \+ LESSON_RECALL_HINT/)
|
|
1663
2033
|
// 客户端:开关读取 + 设置区 checkbox + 候选卡片「沉淀」按钮 + 关闭即整块不渲染
|
|
1664
|
-
assert.match(cli, /state\.feedbackEnabled =
|
|
1665
|
-
assert.match(cli, /
|
|
2034
|
+
assert.match(cli, /state\.feedbackEnabled = cfgKnobOf\(d, 'feedbackEnabled'\)/)
|
|
2035
|
+
assert.match(cli, /setCfg\('feedbackEnabled', e\.target\.checked\)/) // 乐观路径(task-muw5uudk)
|
|
1666
2036
|
assert.match(cli, /'lesson-candidate': \{ color: C\.brand, label: '候选教训', icon: 'book-open' \}/)
|
|
1667
2037
|
assert.match(cli, /rpc\('push-lesson', \{ taskId: props\.taskId, text: String\(m\.text \|\| ''\) \}\)/)
|
|
1668
2038
|
assert.match(cli, /if \(!state\.feedbackEnabled\) msgs = msgs\.filter\(function \(m\) \{ return m && m\.kind !== 'lesson-candidate' \}\)/)
|
|
@@ -2107,8 +2477,11 @@ test('hooks 只许主窗口设置:create-task/update-task RPC 与 task_create/
|
|
|
2107
2477
|
// ===== hooks 接线(host 侧):pre 闸门 spawn / post 补 spawn / hook run 结算分派(直调 + 源码级)=====
|
|
2108
2478
|
// poolCycle 直调 harness(带 subagents):spawnOneShot 走真实路径,捕获 provider/label/prompt;
|
|
2109
2479
|
// run.result 用永不落定的 Promise(本用例只验证「谁被 spawn 了、prompt 对不对」,不触发结算)。
|
|
2480
|
+
// 分流口径(task-muw5gnhv):Worker 默认走 startContinuable → 记入 spawnedContinuable;
|
|
2481
|
+
// verifier/hook run 照旧走 start → 记入 spawned(两族 label 同名,故分开收集避免断言串味)。
|
|
2110
2482
|
function mkHookDispatch(board, over) {
|
|
2111
2483
|
const spawned = []
|
|
2484
|
+
const spawnedContinuable = []
|
|
2112
2485
|
const runs = {}
|
|
2113
2486
|
const boardRef = { b: board }
|
|
2114
2487
|
const ctx = {
|
|
@@ -2121,9 +2494,13 @@ function mkHookDispatch(board, over) {
|
|
|
2121
2494
|
spawned.push({ name, label: req.label, text: req.prompt[0].text, parent: req.parent })
|
|
2122
2495
|
return { id: 'run-' + spawned.length, result: new Promise(function () {}), dispose: async function () {} }
|
|
2123
2496
|
},
|
|
2497
|
+
startContinuable: async (spec) => {
|
|
2498
|
+
spawnedContinuable.push({ name: spec.provider, label: spec.label, text: spec.request.prompt[0].text, parent: spec.request.parent })
|
|
2499
|
+
return { childId: 'child-' + spawnedContinuable.length, messageId: 'msg-' + spawnedContinuable.length }
|
|
2500
|
+
},
|
|
2124
2501
|
},
|
|
2125
2502
|
}
|
|
2126
|
-
const state = { knownSessions: {}, dispatchedEver: {}, badModels: {},
|
|
2503
|
+
const state = { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} }
|
|
2127
2504
|
const dispatch = createDispatch(ctx, state, Object.assign({
|
|
2128
2505
|
rt: async () => boardRef.b,
|
|
2129
2506
|
wt: async () => {},
|
|
@@ -2133,7 +2510,7 @@ function mkHookDispatch(board, over) {
|
|
|
2133
2510
|
sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
|
|
2134
2511
|
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
2135
2512
|
}, over || {}))
|
|
2136
|
-
return { dispatch, spawned, runs, board: boardRef }
|
|
2513
|
+
return { dispatch, spawned, spawnedContinuable, runs, board: boardRef }
|
|
2137
2514
|
}
|
|
2138
2515
|
|
|
2139
2516
|
test('poolCycle 接线①:pre=idle → 不派子任务,改 spawn hook-pre run(prompt=薄框架+契约)', async () => {
|
|
@@ -2146,8 +2523,11 @@ test('poolCycle 接线①:pre=idle → 不派子任务,改 spawn hook-pre ru
|
|
|
2146
2523
|
const board = mkBoard([epi, child, epi2, child2])
|
|
2147
2524
|
const h = mkHookDispatch(board)
|
|
2148
2525
|
await h.dispatch.poolCycle(FULL_SID)
|
|
2149
|
-
|
|
2526
|
+
// 分流断言(task-muw5gnhv):hook run 照旧一次性 start,Worker 走 startContinuable
|
|
2527
|
+
const labels = h.spawned.map(s => s.label).concat(h.spawnedContinuable.map(s => s.label)).sort()
|
|
2150
2528
|
assert.deepEqual(labels, ['hook-pre:epic', 'worker:z1']) // 只 spawn hook + 无关卡片照常派,无 c1 的 Worker
|
|
2529
|
+
assert.deepEqual(h.spawned.map(s => s.label), ['hook-pre:epic']) // hook 不走 continuable
|
|
2530
|
+
assert.deepEqual(h.spawnedContinuable.map(s => s.label), ['worker:z1']) // Worker 走 continuable
|
|
2151
2531
|
assert.equal(child.status, 'pending') // 串行闸门:子任务原地待命
|
|
2152
2532
|
assert.equal(child2.status, 'in-progress') // 无关卡片不受闸门影响
|
|
2153
2533
|
assert.equal(epi.hooks.pre.state, 'running') // 状态机推进(写在卡上,重启可恢复)
|
|
@@ -2165,8 +2545,9 @@ test('poolCycle 接线①:pre=done → 子任务正常派发(闸门放行,
|
|
|
2165
2545
|
const child = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
|
|
2166
2546
|
const h = mkHookDispatch(mkBoard([epi, child]))
|
|
2167
2547
|
await h.dispatch.poolCycle(FULL_SID)
|
|
2168
|
-
assert.equal(h.spawned.length,
|
|
2169
|
-
assert.equal(h.
|
|
2548
|
+
assert.equal(h.spawned.length, 0) // 无 hook run
|
|
2549
|
+
assert.equal(h.spawnedContinuable.length, 1) // 派的是 Worker(默认 continuable 路径)
|
|
2550
|
+
assert.equal(h.spawnedContinuable[0].label, 'worker:c1')
|
|
2170
2551
|
assert.equal(child.status, 'in-progress')
|
|
2171
2552
|
})
|
|
2172
2553
|
|
|
@@ -2206,6 +2587,558 @@ test('poolCycle 接线③:老 epic(无 hooks)→ 子任务了结仍直接
|
|
|
2206
2587
|
assert.equal(epi.hooks, undefined)
|
|
2207
2588
|
})
|
|
2208
2589
|
|
|
2590
|
+
// ===== 可续跑 Worker(task-muw5gnhv 卡1):continuable 分流 / turn 结算观测 / 回退开关 =====
|
|
2591
|
+
// 观察口径:ctx.on('agent/status') 的注册回调 + runsFor 的 rec 形态 + 任务状态机推进。
|
|
2592
|
+
// 分流②③的 spawn 双路 mock 内建「同一次派发只会命中一条路」的互斥断言(调用即记名)。
|
|
2593
|
+
function mkContinuableDispatch(board, over, ctxOver) {
|
|
2594
|
+
const log = { continuable: [], oneShot: [] }
|
|
2595
|
+
const listeners = []
|
|
2596
|
+
// runs 表必须与 state.activeRuns[sid] **同一对象**:spawnOneShot 经 runsFor 写 rec,
|
|
2597
|
+
// agent/status 监听器经 state.activeRuns 读 rec——两处不同表就永远命中不到(真实 session.runsFor
|
|
2598
|
+
// 正是与 activeRuns 同源的访问器)。
|
|
2599
|
+
const runs = {}
|
|
2600
|
+
const activeRuns = { [FULL_SID]: runs }
|
|
2601
|
+
const base = {
|
|
2602
|
+
fs: {}, get: function () { return null },
|
|
2603
|
+
timer: null,
|
|
2604
|
+
// 真实 cordis 的 ctx.effect 会**立即执行**回调取其 disposer(订阅由此当场建立)——
|
|
2605
|
+
// 桩里必须照做,否则 ctx.on 永远不被触达,事件通道的接线就测不到。
|
|
2606
|
+
effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
|
|
2607
|
+
on: function (name, fn) { listeners.push({ name: name, fn: fn }); return function () {} },
|
|
2608
|
+
subagents: {
|
|
2609
|
+
list: () => ['mock'],
|
|
2610
|
+
getProvider: () => ({ inheritsParentContext: false }),
|
|
2611
|
+
start: async (name, req) => {
|
|
2612
|
+
log.oneShot.push({ name: name, label: req.label })
|
|
2613
|
+
return { id: 'run-1', result: new Promise(function () {}), dispose: async function () {} }
|
|
2614
|
+
},
|
|
2615
|
+
startContinuable: async (spec) => {
|
|
2616
|
+
log.continuable.push({ name: spec.provider, label: spec.label, text: spec.request.prompt[0].text })
|
|
2617
|
+
return { childId: 'child-abc', messageId: 'msg-1' }
|
|
2618
|
+
},
|
|
2619
|
+
},
|
|
2620
|
+
}
|
|
2621
|
+
const dispatch = createDispatch(
|
|
2622
|
+
Object.assign(base, ctxOver || {}),
|
|
2623
|
+
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: activeRuns },
|
|
2624
|
+
Object.assign({
|
|
2625
|
+
rt: async () => board, wt: async () => {}, mutateLocked: async (sid, fn) => fn(board), kickCycle: () => {},
|
|
2626
|
+
rootForSession: () => ({ id: FULL_SID }),
|
|
2627
|
+
withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
|
|
2628
|
+
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
2629
|
+
}, over || {}))
|
|
2630
|
+
return { dispatch, log, listeners, runs, board }
|
|
2631
|
+
}
|
|
2632
|
+
const flush = () => new Promise(r => setImmediate(r))
|
|
2633
|
+
|
|
2634
|
+
// 子进程探针源码(可续跑 Worker⑤ 用):只在子进程里执行「真跑一次 continuable 派发 + 真读会话日志 + 真结算」。
|
|
2635
|
+
// 为什么必须子进程:findRunLog 走 os.homedir()(进程内首次调用即缓存),临时 HOME 只有在进程启动前
|
|
2636
|
+
// 设好才生效——同进程改 env 既无效又会带偏其它用例与真实看板目录。
|
|
2637
|
+
const PROBE_CONTINUABLE_SOURCE = `
|
|
2638
|
+
const { createDispatch } = await import('./lib/dispatch.mjs')
|
|
2639
|
+
const SID = 'session-test-0000-0000-000000000000'
|
|
2640
|
+
const listeners = []
|
|
2641
|
+
const runs = {}
|
|
2642
|
+
let failedFail = 0
|
|
2643
|
+
const origErr = console.error
|
|
2644
|
+
console.error = function () { var s = Array.prototype.join.call(arguments, ' '); if (/未读到 assistant 文本/.test(s)) failedFail++; origErr.apply(console, arguments) }
|
|
2645
|
+
const ctx = {
|
|
2646
|
+
fs: {}, get: () => null, timer: null,
|
|
2647
|
+
effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
|
|
2648
|
+
on: function (n, f) { listeners.push(f); return function () {} },
|
|
2649
|
+
subagents: {
|
|
2650
|
+
list: () => ['mock'],
|
|
2651
|
+
getProvider: () => ({ inheritsParentContext: false }),
|
|
2652
|
+
start: async () => ({ id: 'run-1', result: new Promise(() => {}), dispose: async () => {} }),
|
|
2653
|
+
startContinuable: async () => ({ childId: 'child-ok', messageId: 'm1' }),
|
|
2654
|
+
},
|
|
2655
|
+
}
|
|
2656
|
+
const t = { id: 'w6', title: '卡', description: '', status: 'pending', priority: 'medium', tags: [], parentId: null, assignMode: 'auto', assignee: null, context: { instructions: '' }, acceptance: '', dependsOn: [], pipeline: 'full', claimedBy: null, claimedAt: null, createdAt: '2026-01-01T00:00:00Z', resolvedAt: null, history: [], messages: [] }
|
|
2657
|
+
const board = { version: 11, ownerSession: SID, boardMode: 'auto', maxWorkers: 3, tasks: [t] }
|
|
2658
|
+
const d = createDispatch(ctx, { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: { [SID]: runs } }, {
|
|
2659
|
+
rt: async () => board, wt: async () => {}, mutateLocked: async (s, f) => f(board), kickCycle: () => {}, rootForSession: () => ({ id: SID }),
|
|
2660
|
+
withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true, pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
2661
|
+
})
|
|
2662
|
+
await d.poolCycle(SID)
|
|
2663
|
+
const rec = runs['w6']
|
|
2664
|
+
listeners[0]({ agent: { session: { id: 'child-ok' } }, status: 'running' })
|
|
2665
|
+
listeners[0]({ agent: { session: { id: 'child-ok' } }, status: 'idle' })
|
|
2666
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r))
|
|
2667
|
+
console.log(JSON.stringify({ status: t.status, continual: !!(rec && rec.continuable), deliverable: t.deliverable || null, failedFail: failedFail }))
|
|
2668
|
+
`
|
|
2669
|
+
|
|
2670
|
+
test('可续跑 Worker①:worker 走 startContinuable(rec 持 childId),verifier 照旧走 start(分流断言)', async () => {
|
|
2671
|
+
const w = mkTask({ id: 'w1', title: 'Worker 卡', status: 'pending' })
|
|
2672
|
+
const v = mkTask({ id: 'v1', title: '待验收卡', status: 'verifying' })
|
|
2673
|
+
const h = mkContinuableDispatch(mkBoard([w, v]))
|
|
2674
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2675
|
+
// Worker → continuable(默认开关开)
|
|
2676
|
+
assert.deepEqual(h.log.continuable.map(x => x.label), ['worker:w1'])
|
|
2677
|
+
// Verifier → 一次性 start(不因开关改道)
|
|
2678
|
+
assert.deepEqual(h.log.oneShot.map(x => x.label), ['verifier:v1'])
|
|
2679
|
+
// rec 结构:id=childId(会话 id 语义不变,t.runs/claimedBy 都靠它)、持 childId、标记 continuable、无 run
|
|
2680
|
+
const rec = h.runs['w1']
|
|
2681
|
+
assert.equal(rec.id, 'child-abc'); assert.equal(rec.childId, 'child-abc')
|
|
2682
|
+
assert.equal(rec.continuable, true); assert.equal(rec.run, null); assert.equal(rec.settled, false); assert.equal(rec.ran, false)
|
|
2683
|
+
assert.equal(rec.role, 'worker'); assert.equal(rec.taskId, 'w1'); assert.equal(typeof rec.startedAt, 'number')
|
|
2684
|
+
// 任务侧接线照旧:claimedBy 落 childId(详情页/会话跳转语义不变)+ runs 历史留档同 id
|
|
2685
|
+
assert.equal(w.claimedBy, 'child-abc')
|
|
2686
|
+
assert.deepEqual(w.runs.map(r => r.id), ['child-abc'])
|
|
2687
|
+
// verifier 的 rec 仍是旧形态(有 run、无 continuable)
|
|
2688
|
+
assert.equal(h.runs['v1'].continuable, undefined)
|
|
2689
|
+
assert.equal(String(h.runs['v1'].id), 'run-1')
|
|
2690
|
+
// 订阅已注册且事件名正确(回退开关不影响的公共通道)
|
|
2691
|
+
assert.deepEqual(h.listeners.map(l => l.name), ['agent/status'])
|
|
2692
|
+
})
|
|
2693
|
+
|
|
2694
|
+
test('可续跑 Worker②:agent/status running→idle → settle 推进任务(真跑事件回调,非源码断言)', async () => {
|
|
2695
|
+
const t = mkTask({ id: 'w2', title: '会失败的卡', status: 'pending' })
|
|
2696
|
+
const h = mkContinuableDispatch(mkBoard([t]))
|
|
2697
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2698
|
+
assert.equal(t.status, 'in-progress')
|
|
2699
|
+
const fire = (status) => h.listeners[0].fn({ agent: { session: { id: 'child-abc' } }, status: status })
|
|
2700
|
+
// ① running:只标记「这一轮真跑起来了」,绝不结算(rec 仍在活跃表)
|
|
2701
|
+
fire('running')
|
|
2702
|
+
assert.equal(h.runs['w2'].ran, true)
|
|
2703
|
+
assert.equal(t.status, 'in-progress')
|
|
2704
|
+
assert.equal(h.runs['w2'].settled, false)
|
|
2705
|
+
// ② idle:turn 结束 → 走 settleWorker 的任务推进语义。本用例的环境里读不到子会话日志(无真实日志文件),
|
|
2706
|
+
// 故走「空文本按失败结算」的既有降级臂:in-progress → pending 重排(retryCount 首次 fail = 1),
|
|
2707
|
+
// 而不是被误判成「空完成」推进到 verifying。
|
|
2708
|
+
fire('idle')
|
|
2709
|
+
await flush()
|
|
2710
|
+
assert.equal(t.status, 'pending')
|
|
2711
|
+
assert.equal(t.retryCount, 1)
|
|
2712
|
+
assert.equal(t.claimedBy, null)
|
|
2713
|
+
assert.equal(h.runs['w2'], undefined) // 结算即摘除活跃表项
|
|
2714
|
+
const last = t.history[t.history.length - 1]
|
|
2715
|
+
assert.match(last.note, /worker 失败/)
|
|
2716
|
+
// 幂等:rec 已摘除后再来 idle 事件不炸、不重复计数
|
|
2717
|
+
fire('idle'); await flush()
|
|
2718
|
+
assert.equal(t.retryCount, 1)
|
|
2719
|
+
// 非本 rec 的会话 id 不误伤:另一个 child 的 idle 事件被忽略
|
|
2720
|
+
const t2 = mkTask({ id: 'w3', title: '另一张', status: 'pending' })
|
|
2721
|
+
const h2 = mkContinuableDispatch(mkBoard([t2]))
|
|
2722
|
+
await h2.dispatch.poolCycle(FULL_SID)
|
|
2723
|
+
h2.listeners[0].fn({ agent: { session: { id: '别人的会话' } }, status: 'running' })
|
|
2724
|
+
h2.listeners[0].fn({ agent: { session: { id: '别人的会话' } }, status: 'idle' })
|
|
2725
|
+
await flush()
|
|
2726
|
+
assert.equal(t2.status, 'in-progress'); assert.equal(h2.runs['w3'].settled, false)
|
|
2727
|
+
// 未观测到 running 的伪 idle 被 rec.ran 守卫挡住(不拿瞬时 idle 结算成「空文本失败」)
|
|
2728
|
+
h2.listeners[0].fn({ agent: { session: { id: 'child-abc' } }, status: 'idle' })
|
|
2729
|
+
await flush()
|
|
2730
|
+
assert.equal(t2.status, 'in-progress'); assert.equal(h2.runs['w3'].settled, false)
|
|
2731
|
+
})
|
|
2732
|
+
|
|
2733
|
+
test('可续跑 Worker②b:事件 payload 身份契约——agent 无 id 字段,身份在 agent.session.id(2026-10-06 热修防再发)', async () => {
|
|
2734
|
+
// 事故根源:dsh-agent 的 Agent 接口(runtime-types.d.ts)没有 id 字段——身份在 agent.session.id。
|
|
2735
|
+
// 旧实现读 agent.id 恒 undefined → cid='' 静默早退 → 事件结算通道自卡1上线从未在生产触发。
|
|
2736
|
+
// 本条用「无 id 字段的真实形状」mock 锁住契约;agent.id 兜底分支也一并覆盖。
|
|
2737
|
+
const t = mkTask({ id: 'w2b', title: '身份契约卡', status: 'pending' })
|
|
2738
|
+
const h = mkContinuableDispatch(mkBoard([t]))
|
|
2739
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2740
|
+
assert.equal(t.status, 'in-progress')
|
|
2741
|
+
// 真实形状:agent 只有 session.id,没有 id——必须命中 rec 并结算(harness mock 恒返回 childId 'child-abc')
|
|
2742
|
+
h.listeners[0].fn({ agent: { session: { id: 'child-abc' } }, status: 'running' })
|
|
2743
|
+
assert.equal(h.runs['w2b'].ran, true)
|
|
2744
|
+
h.listeners[0].fn({ agent: { session: { id: 'child-abc' } }, status: 'idle' })
|
|
2745
|
+
await flush()
|
|
2746
|
+
assert.equal(t.status, 'pending') // 空文本降级臂回 pending(同②口径)
|
|
2747
|
+
assert.equal(h.runs['w2b'], undefined)
|
|
2748
|
+
// 防回归哨兵:若有人把身份读回 agent.id,上面两发事件将静默无效果——本断言会立刻红
|
|
2749
|
+
const t2 = mkTask({ id: 'w2c', title: 'payload 缺 agent', status: 'pending' })
|
|
2750
|
+
const h2 = mkContinuableDispatch(mkBoard([t2]))
|
|
2751
|
+
await h2.dispatch.poolCycle(FULL_SID)
|
|
2752
|
+
h2.listeners[0].fn({ status: 'idle' }) // 无 agent:静默跳过不炸
|
|
2753
|
+
h2.listeners[0].fn({ agent: null, status: 'idle' }) // agent=null:同上
|
|
2754
|
+
await flush()
|
|
2755
|
+
assert.equal(t2.status, 'in-progress'); assert.equal(h2.runs['w2c'].settled, false)
|
|
2756
|
+
})
|
|
2757
|
+
|
|
2758
|
+
test('可续跑 Worker③:workerContinuable=false → 逐字回退一次性路径(零 startContinuable 调用)', async () => {
|
|
2759
|
+
const t = mkTask({ id: 'w4', title: '回退卡', status: 'pending' })
|
|
2760
|
+
const board = Object.assign(mkBoard([t]), { workerContinuable: false })
|
|
2761
|
+
const h = mkContinuableDispatch(board)
|
|
2762
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2763
|
+
assert.deepEqual(h.log.continuable, []) // 回退:continuable 路径一次都不进
|
|
2764
|
+
assert.deepEqual(h.log.oneShot.map(x => x.label), ['worker:w4'])
|
|
2765
|
+
const rec = h.runs['w4']
|
|
2766
|
+
assert.equal(rec.continuable, undefined) // 旧 rec 形态逐字不变
|
|
2767
|
+
assert.equal(rec.childId, undefined)
|
|
2768
|
+
assert.equal(String(rec.id), 'run-1') // id 仍等于 run.id
|
|
2769
|
+
assert.ok(rec.run && typeof rec.run.dispose === 'function') // 旧结算通道(run.result + dispose)在位
|
|
2770
|
+
assert.equal(t.claimedBy, 'run-1') // 任务侧写回同旧口径
|
|
2771
|
+
})
|
|
2772
|
+
|
|
2773
|
+
test('可续跑 Worker④:startContinuable 失败 → spawn 回 null,任务回 pending(不卡 spawn-pending)', async () => {
|
|
2774
|
+
const t = mkTask({ id: 'w5', title: '起不来的卡', status: 'pending' })
|
|
2775
|
+
const h = mkContinuableDispatch(mkBoard([t]), {}, {
|
|
2776
|
+
subagents: {
|
|
2777
|
+
list: () => ['mock'],
|
|
2778
|
+
getProvider: () => ({ inheritsParentContext: false }),
|
|
2779
|
+
start: async () => { throw new Error('不该走一次性路径') },
|
|
2780
|
+
startContinuable: async () => { throw new Error('continuation unavailable') },
|
|
2781
|
+
},
|
|
2782
|
+
})
|
|
2783
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2784
|
+
assert.equal(t.status, 'pending') // 回收重新排队(不是卡在 in-progress/spawn-pending)
|
|
2785
|
+
assert.equal(t.claimedBy, null)
|
|
2786
|
+
assert.equal(t.claimedAt, null)
|
|
2787
|
+
assert.equal(h.runs['w5'], undefined) // 无活跃 rec(失败不占并发位)
|
|
2788
|
+
const last = t.history[t.history.length - 1]
|
|
2789
|
+
assert.match(last.note, /spawn 失败,回收重新排队/)
|
|
2790
|
+
})
|
|
2791
|
+
|
|
2792
|
+
test('可续跑 Worker⑤:idle 后从子会话 v4 日志读到助手文本 → 走文本通道推进 verifying(真读真日志)', async () => {
|
|
2793
|
+
// 子会话日志定位走 os.homedir()(进程内首次调用即缓存),故用临时 HOME + child_process 隔离子进程跑,
|
|
2794
|
+
// 不能在主进程改 HOME——同进程的其它用例/真实看板目录都会被带偏。
|
|
2795
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tbc-'))
|
|
2796
|
+
const frames = [{ type: 'assistant/message', data: { message: { role: 'assistant', content: [{ type: 'text', text: '## 开发描述\n续跑 Worker 完工\n## 自测情况\nok' }] } } }]
|
|
2797
|
+
const chunks = [zlib.zstdCompressSync(Buffer.from(JSON.stringify(frames[0]) + '\n'))]
|
|
2798
|
+
fs.mkdirSync(path.join(tmp, '.dsh', 'sessions', 'b1', 'child-ok'), { recursive: true })
|
|
2799
|
+
fs.writeFileSync(path.join(tmp, '.dsh', 'sessions', 'b1', 'child-ok', 'session.v4.jsonl.zstd'), Buffer.concat(chunks))
|
|
2800
|
+
try {
|
|
2801
|
+
const child = spawnSync(process.execPath, ['-e', PROBE_CONTINUABLE_SOURCE], { env: Object.assign({}, process.env, { HOME: tmp, USERPROFILE: tmp }), encoding: 'utf8' })
|
|
2802
|
+
assert.equal(child.status, 0, 'probe 失败: ' + child.stderr)
|
|
2803
|
+
const out = JSON.parse(child.stdout.trim().split('\n').pop())
|
|
2804
|
+
assert.equal(out.status, 'verifying') // 文本通道:有助手文本 → 正常推进 verifying
|
|
2805
|
+
assert.equal(out.continual, true) // 走的确实是 continuable 路径
|
|
2806
|
+
assert.match(out.deliverable.summary, /续跑 Worker 完工/) // 交付物来自真日志读到的文本(非空完成)
|
|
2807
|
+
assert.equal(out.failedFail, 0) // 没有走「空文本按失败」的降级臂
|
|
2808
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
2809
|
+
})
|
|
2810
|
+
|
|
2811
|
+
test('可续跑 Worker 接线(源码级):事件订阅走 ctx.effect 回收 + 硬超时 interrupt 留存 + 重派续跑接线', () => {
|
|
2812
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
2813
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
2814
|
+
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
2815
|
+
// ① 双路 spawn:Worker+开关开才走 startContinuable(verifier/hook 不受影响)
|
|
2816
|
+
assert.match(dsp, /var useContinuable = role === 'worker' && c\.workerContinuable !== false/)
|
|
2817
|
+
assert.match(dsp, /var cs = await subagents\.startContinuable\(\{ provider: providerName, label: req\.label, request: req, signal: req\.signal \}\)/)
|
|
2818
|
+
// ② 结算观测:agent/status 订阅 + running→idle + rec.ran 二次守卫 + effect 回收
|
|
2819
|
+
assert.match(dsp, /ctx\.effect\(function \(\) \{ return ctx\.on\('agent\/status', onAgentStatus\) \}\)/)
|
|
2820
|
+
assert.match(dsp, /if \(status !== 'idle' && status !== 'running'\) return/) // running 事件必须放行(rec.ran 的唯一来源)
|
|
2821
|
+
assert.match(dsp, /if \(status === 'running'\) \{ rec\.ran = true; return \}/)
|
|
2822
|
+
assert.match(dsp, /if \(!rec\.ran \|\| rec\.settled\) return/)
|
|
2823
|
+
assert.match(dsp, /await settleRun\(sid, rec, \{ output: \[\{ type: 'text', text: text \}\], stopReason: text\.trim\(\) \? 'completed' : 'error' \}, null\)/)
|
|
2824
|
+
// ③ 卡2 Step1:硬超时/失败结算对 continuable 子会话改 interrupt(留存不销毁),一次性仍 dispose;
|
|
2825
|
+
// authority 形状取 dsh-subagent 的 SubagentInterruptAuthority,interrupt 异常被吞进 resumeBlocked 不阻断结算。
|
|
2826
|
+
assert.match(dsp, /async function endContinuable\(sid, rec\) \{/)
|
|
2827
|
+
assert.match(dsp, /subagents\.interrupt\(rec\.childId, \{ kind: 'ancestor', agent: parent \}\)/)
|
|
2828
|
+
assert.match(dsp, /if \(rec\.continuable\) await endContinuable\(sid, rec\)\s*\n\s*else await rec\.run\.dispose\(\)/)
|
|
2829
|
+
assert.match(dsp, /rec\.resumeBlocked = true/)
|
|
2830
|
+
assert.match(dsp, /withTimeout\(abortableP, hardMs, rec\.role \+ ':' \+ t\.id\)/)
|
|
2831
|
+
// ④ 文本兜底复用 usage.mjs 现成读取器(与 agent-activity 同一套日志定位/分帧)+ 续跑基线(只认本轮新写字节)
|
|
2832
|
+
assert.match(dsp, /var log = findRunLog\(childId, sessionsRoot\)/)
|
|
2833
|
+
assert.match(dsp, /var buf = readLogBytes\(log, tail\)/)
|
|
2834
|
+
assert.match(dsp, /childSessionOutput\(rec\.childId, rec\.baselineBytes\)/)
|
|
2835
|
+
assert.match(dsp, /baselineBytes: logSizeOf\(childId\)/)
|
|
2836
|
+
// ⑤ 卡2 Step2/Step3:重派续跑接线(sendMessage 冷复活 + 薄框架断点续跑指令 + runs[] 续跑记录 +
|
|
2837
|
+
// 失败回退 fresh spawn)+ 三连败计数不受续跑影响(续跑同样走 settleWorker 的 retryCount 口径)
|
|
2838
|
+
assert.match(dsp, /await subagents\.sendMessage\(parent, childId, \[\{ type: 'text', text: text \}\], \{ signal: makeSignal\(\) \}\)/)
|
|
2839
|
+
assert.match(dsp, /【断点续跑】你之前执行此任务被中断(第 ' \+ attempt \+ ' 次尝试)/)
|
|
2840
|
+
assert.match(dsp, /await recordRunHistory\(sid, t\.id, 'worker', childId, '', c\.hardTimeoutMin, true, true\)/)
|
|
2841
|
+
assert.match(dsp, /if \(sp\.role === 'worker' && c\.workerContinuable !== false\) \{/)
|
|
2842
|
+
assert.match(dsp, /kind: 'resume-fallback'/)
|
|
2843
|
+
assert.match(dsp, /if \(r\.continuable !== true \|\| r\.noResume === true\) return ''/) // 续跑资格:一次性 run/已标 noResume 一律不够格
|
|
2844
|
+
assert.match(dsp, /if \(r\.outcome !== 'timeout\/error' && r\.outcome !== 'incomplete'\) return ''/) // 只续「超时/失败」的断点
|
|
2845
|
+
// ⑥ 板级开关三件套(cfg 兜底 / normalizeBoard 补缺省 / seed 初值 / set-board-config 白名单 / get-tasks 透出)
|
|
2846
|
+
assert.match(coreSrc, /workerContinuable: d\.workerContinuable !== false/)
|
|
2847
|
+
assert.match(coreSrc, /if \(typeof d\.workerContinuable !== 'boolean'\) d\.workerContinuable = true/)
|
|
2848
|
+
assert.match(coreSrc, /epicSplit: true, workerContinuable: true, minWorkers: 1/)
|
|
2849
|
+
assert.match(rpcSrc, /else if \(args\.key === 'workerContinuable'\) d\.workerContinuable = !!args\.value/)
|
|
2850
|
+
assert.match(rpcSrc, /d\.workerContinuable = cfg\(d\)\.workerContinuable/)
|
|
2851
|
+
// ⑦ 手动终止/活动查询对 continuable rec 不炸(id 统一取 rec.id,dispose 分路)
|
|
2852
|
+
assert.match(rpcSrc, /try \{ if \(rec\.run\) await rec\.run\.dispose\(\) \} catch \(_\) \{\}/)
|
|
2853
|
+
// ⑧ 卸载清理对 continuable rec 不炸(判空 + 立 settled 旗 + 原位清空)
|
|
2854
|
+
assert.match(dsp, /if \(r0 && r0\.run\) r0\.run\.dispose\(\); if \(r0\) r0\.settled = true/)
|
|
2855
|
+
})
|
|
2856
|
+
|
|
2857
|
+
test('可续跑 Worker⑥:插件卸载清理对 continuable rec 不炸且清空表(真跑 disposer)', async () => {
|
|
2858
|
+
const t = mkTask({ id: 'w7', title: '卸载卡', status: 'pending' })
|
|
2859
|
+
const teardown = []
|
|
2860
|
+
const h = mkContinuableDispatch(mkBoard([t]), {}, { effect: function (f) { var d = f(); teardown.push(d); return function () { if (typeof d === 'function') d() } } })
|
|
2861
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2862
|
+
assert.equal(typeof h.runs['w7'], 'object') // 派发后有活跃 rec
|
|
2863
|
+
for (const fn of teardown) if (typeof fn === 'function') fn() // 模拟插件停止:跑 ctx.effect 的 disposer
|
|
2864
|
+
assert.equal(h.runs['w7'], undefined) // 原位清空(旧引用看不到残留 rec)
|
|
2865
|
+
assert.equal(t.status, 'in-progress') // 清理只放行结算,不擅自推进任务状态
|
|
2866
|
+
})
|
|
2867
|
+
|
|
2868
|
+
// ===== 可续跑 Worker(task-muw5h2ps 卡2):硬超时 interrupt + 重派冷复活续跑 + 失败回退 fresh spawn =====
|
|
2869
|
+
// 观察口径:真跑 poolCycle(真 spawnOneShot / 真 tryResumeWorker)+ 真事件通道(agent/status)+ 卡上留档
|
|
2870
|
+
// (t.runs/结局)——不断言实现细节,断言「谁被调了、任务变成什么样」。
|
|
2871
|
+
// 断言①~⑤ 对应卡2 三条 Step:①硬超时→interrupt 留存(不 dispose);②重派 sendMessage 续跑(不 spawn 新 Worker);
|
|
2872
|
+
// ③续跑失败回退 fresh spawn + 任务消息留说明;④interrupt 失败不卡死且重派回退;⑤回退开关下一次性路径零变化。
|
|
2873
|
+
test('可续跑 Worker⑦(卡2①):硬超时 → interrupt 留存(不 dispose),任务回 pending 且 run 留「continuable+超时」结局', async () => {
|
|
2874
|
+
const parent = { id: FULL_SID }
|
|
2875
|
+
const calls = { interrupt: [] }
|
|
2876
|
+
let disposed = 0
|
|
2877
|
+
const t = mkTask({ id: 'r1', title: '超时卡', status: 'pending' })
|
|
2878
|
+
const h = mkContinuableDispatch(mkBoard([t]), {
|
|
2879
|
+
rootForSession: () => parent,
|
|
2880
|
+
// 硬超时臂:直接拒绝(等价 withTimeout 到期)——走的是与真实到期逐字相同的失败结算
|
|
2881
|
+
withTimeout: () => Promise.reject(new Error('timeout: worker:r1 after 120min')),
|
|
2882
|
+
}, {
|
|
2883
|
+
subagents: {
|
|
2884
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
2885
|
+
start: async () => ({ id: 'run-1', result: new Promise(function () {}), dispose: async function () { disposed++ } }),
|
|
2886
|
+
startContinuable: async () => ({ childId: 'child-abc', messageId: 'msg-1' }),
|
|
2887
|
+
interrupt: (id, auth) => calls.interrupt.push({ id: id, auth: auth }),
|
|
2888
|
+
},
|
|
2889
|
+
})
|
|
2890
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2891
|
+
await flush(); await flush(); await flush()
|
|
2892
|
+
// ① 硬超时收尾 = interrupt(子会话 idle 留存,等重派冷复活),不是销毁/丢弃
|
|
2893
|
+
assert.equal(calls.interrupt.length, 1)
|
|
2894
|
+
assert.equal(calls.interrupt[0].id, 'child-abc')
|
|
2895
|
+
// authority 形状 = dsh-subagent 的 SubagentInterruptAuthority:{kind:'ancestor', agent: 活的直接父 Agent}
|
|
2896
|
+
assert.deepEqual(calls.interrupt[0].auth, { kind: 'ancestor', agent: parent })
|
|
2897
|
+
assert.equal(disposed, 0) // continuable 路径绝不 dispose(rec 也没有 run)
|
|
2898
|
+
// 任务侧语义与现状一致:失败结算 → 回 pending 重排、count +1
|
|
2899
|
+
assert.equal(t.status, 'pending')
|
|
2900
|
+
assert.equal(t.retryCount, 1)
|
|
2901
|
+
assert.equal(t.claimedBy, null)
|
|
2902
|
+
assert.equal(h.runs['r1'], undefined) // 结算即摘除活跃表项
|
|
2903
|
+
// runs 留档是续跑资格的唯一依据:continuable + timeout/error(首派不带 resume 标记)
|
|
2904
|
+
const last = t.runs[t.runs.length - 1]
|
|
2905
|
+
assert.equal(last.outcome, 'timeout/error')
|
|
2906
|
+
assert.equal(last.continuable, true)
|
|
2907
|
+
assert.equal(last.resume, undefined)
|
|
2908
|
+
assert.equal(last.noResume, undefined)
|
|
2909
|
+
})
|
|
2910
|
+
|
|
2911
|
+
test('可续跑 Worker⑧(卡2②):重派命中续跑 → sendMessage 冷复活(零新 spawn),指令含「断点续跑/第 2 次尝试」,runs[] 追加 resume 记录', async () => {
|
|
2912
|
+
const parent = { id: FULL_SID }
|
|
2913
|
+
const sent = []
|
|
2914
|
+
const spawned = []
|
|
2915
|
+
// 上次 continuable Worker run 结局=超时(形状即⑦那条路径产生的真实留档)
|
|
2916
|
+
const t = mkTask({ id: 'r2', title: '续跑卡', status: 'pending', runs: [{ role: 'worker', id: 'child-old', at: '2026-01-01T00:00:00.000Z', model: '', outcome: 'timeout/error', hardMin: 120, continuable: true }] })
|
|
2917
|
+
const h = mkContinuableDispatch(mkBoard([t]), { rootForSession: () => parent }, {
|
|
2918
|
+
subagents: {
|
|
2919
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
2920
|
+
start: async () => { spawned.push('one-shot'); return { id: 'run-1', result: new Promise(function () {}), dispose: async function () {} } },
|
|
2921
|
+
startContinuable: async () => { spawned.push('continuable'); return { childId: 'child-new', messageId: 'msg-1' } },
|
|
2922
|
+
sendMessage: async (sender, targetId, content, options) => { sent.push({ sender: sender, targetId: targetId, content: content, options: options }); return 'msg-2' },
|
|
2923
|
+
},
|
|
2924
|
+
})
|
|
2925
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2926
|
+
// ② 发的是续跑指令,目标是上次那个子会话
|
|
2927
|
+
assert.equal(sent.length, 1)
|
|
2928
|
+
assert.equal(sent[0].sender, parent) // sender = 活的直接父 Agent(服务端按邻接校验)
|
|
2929
|
+
assert.equal(sent[0].targetId, 'child-old')
|
|
2930
|
+
const txt = sent[0].content[0].text
|
|
2931
|
+
assert.match(txt, /【断点续跑】/)
|
|
2932
|
+
assert.match(txt, /第 2 次尝试/) // N = 已有 Worker run 数(1) + 本次
|
|
2933
|
+
assert.match(txt, /git status\/diff/) // 先盘点工作树
|
|
2934
|
+
assert.match(txt, /从断点继续/)
|
|
2935
|
+
assert.match(txt, /任务契约与验收标准见上文历史/)
|
|
2936
|
+
assert.match(txt, /board_report escalate/) // 吃不准就上报
|
|
2937
|
+
assert.ok(sent[0].options && sent[0].options.signal, 'sendMessage 选项形态必须是 SubagentSendMessageOptions { signal }')
|
|
2938
|
+
assert.deepEqual(spawned, []) // 零新 spawn(两条 spawn 路都没进)
|
|
2939
|
+
// 任务侧:占位 claim 换成真实 childId;活跃 rec 重新挂上(turn 结算仍靠 agent/status 事件)
|
|
2940
|
+
assert.equal(t.status, 'in-progress')
|
|
2941
|
+
assert.equal(t.claimedBy, 'child-old')
|
|
2942
|
+
const rec = h.runs['r2']
|
|
2943
|
+
assert.equal(rec.childId, 'child-old'); assert.equal(rec.continuable, true); assert.equal(rec.resumed, true)
|
|
2944
|
+
assert.equal(rec.ran, false); assert.equal(rec.settled, false)
|
|
2945
|
+
assert.equal(typeof rec.baselineBytes, 'number') // 续跑基线(只认本轮新写字节)
|
|
2946
|
+
// runs[] 追加一条续跑记录:role=worker / id=childId(同一会话)/ resume 标记
|
|
2947
|
+
const last = t.runs[t.runs.length - 1]
|
|
2948
|
+
assert.equal(last.role, 'worker'); assert.equal(last.id, 'child-old')
|
|
2949
|
+
assert.equal(last.resume, true); assert.equal(last.continuable, true); assert.equal(last.outcome, 'running')
|
|
2950
|
+
// 续跑轮同样能被事件结算(与首派同一条通道):running→idle(本环境读不到子会话日志 → 走「空文本按失败」)
|
|
2951
|
+
h.listeners[0].fn({ agent: { session: { id: 'child-old' } }, status: 'running' })
|
|
2952
|
+
assert.equal(h.runs['r2'].ran, true)
|
|
2953
|
+
h.listeners[0].fn({ agent: { session: { id: 'child-old' } }, status: 'idle' })
|
|
2954
|
+
await flush(); await flush()
|
|
2955
|
+
assert.equal(t.status, 'pending') // 续跑也算一次尝试:失败照常重排
|
|
2956
|
+
assert.equal(t.retryCount, 1) // 三连败计数口径不受续跑影响(Step3)
|
|
2957
|
+
assert.equal(h.runs['r2'], undefined)
|
|
2958
|
+
assert.equal(t.runs[t.runs.length - 1].outcome, 'incomplete') // 续跑轮结局落档(下一轮仍够格续跑)
|
|
2959
|
+
})
|
|
2960
|
+
|
|
2961
|
+
test('可续跑 Worker⑨(卡2③):续跑失败(NOT_RESUMABLE)→ 回退 fresh spawn,任务消息留一行说明并随首条 prompt 注入', async () => {
|
|
2962
|
+
const parent = { id: FULL_SID }
|
|
2963
|
+
const sent = []
|
|
2964
|
+
const prompts = []
|
|
2965
|
+
const t = mkTask({ id: 'r3', title: '回退卡', status: 'pending', runs: [{ role: 'worker', id: 'child-dead', at: '2026-01-01T00:00:00.000Z', model: '', outcome: 'incomplete', hardMin: 120, continuable: true }] })
|
|
2966
|
+
const h = mkContinuableDispatch(mkBoard([t]), { rootForSession: () => parent }, {
|
|
2967
|
+
subagents: {
|
|
2968
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
2969
|
+
start: async () => { throw new Error('不该走一次性路径') },
|
|
2970
|
+
startContinuable: async (spec) => { prompts.push(spec.request.prompt[0].text); return { childId: 'child-new', messageId: 'msg-1' } },
|
|
2971
|
+
sendMessage: async (sender, targetId) => { sent.push(targetId); throw new Error('subagent/not-resumable') },
|
|
2972
|
+
},
|
|
2973
|
+
})
|
|
2974
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
2975
|
+
assert.deepEqual(sent, ['child-dead']) // 确实先试了续跑(冷复活失败)
|
|
2976
|
+
assert.equal(prompts.length, 1) // 失败 → 回退 fresh spawn(全新 Worker)
|
|
2977
|
+
assert.equal(t.claimedBy, 'child-new')
|
|
2978
|
+
// 任务消息里留一行说明(详情页可见)
|
|
2979
|
+
const note = (t.messages || []).find((m) => m.kind === 'resume-fallback')
|
|
2980
|
+
assert.ok(note, '续跑回退必须留说明')
|
|
2981
|
+
assert.match(note.text, /断点续跑不可用/)
|
|
2982
|
+
assert.match(note.text, /not-resumable/) // 原因带出,可追溯
|
|
2983
|
+
// 说明写在 spawn **之前**:新 Worker 的首条 prompt 里就能看到(否则「留一行说明」只是给人事后翻账)
|
|
2984
|
+
assert.match(prompts[0], /### \[resume-fallback\]/)
|
|
2985
|
+
assert.match(prompts[0], /断点续跑不可用/)
|
|
2986
|
+
})
|
|
2987
|
+
|
|
2988
|
+
test('可续跑 Worker⑩(卡2④):interrupt 失败 → 结算不卡死(照常回 pending)且 run 标 noResume,重派直接 fresh spawn 不空唤醒', async () => {
|
|
2989
|
+
const parent = { id: FULL_SID }
|
|
2990
|
+
const sent = []
|
|
2991
|
+
const spawned = []
|
|
2992
|
+
let timeouts = 0
|
|
2993
|
+
const t = mkTask({ id: 'r4', title: '打断失败卡', status: 'pending' })
|
|
2994
|
+
const h = mkContinuableDispatch(mkBoard([t]), {
|
|
2995
|
+
rootForSession: () => parent,
|
|
2996
|
+
// 只有第一轮的硬超时臂到期(第二轮让它永不落定,便于断言「重派后停在 in-progress」)
|
|
2997
|
+
withTimeout: () => { timeouts++; return timeouts === 1 ? Promise.reject(new Error('timeout: worker:r4 after 120min')) : new Promise(function () {}) },
|
|
2998
|
+
}, {
|
|
2999
|
+
subagents: {
|
|
3000
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
3001
|
+
start: async () => { throw new Error('不该走一次性路径') },
|
|
3002
|
+
startContinuable: async () => { spawned.push('spawn'); return { childId: 'child-' + spawned.length, messageId: 'm' } },
|
|
3003
|
+
interrupt: () => { throw new Error('UNAUTHORIZED: 会话已死') },
|
|
3004
|
+
sendMessage: async (sender, targetId) => { sent.push(targetId); return 'msg' },
|
|
3005
|
+
},
|
|
3006
|
+
})
|
|
3007
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
3008
|
+
await flush(); await flush(); await flush()
|
|
3009
|
+
// 第一轮:正常首派(continuable 路径)→ 硬超时结算 → interrupt 抛错被吞进结算(不抛穿 poolCycle、
|
|
3010
|
+
// 不阻断状态机):任务照常回 pending 重排
|
|
3011
|
+
assert.deepEqual(spawned, ['spawn']) // 首派确实走了 continuable 路径
|
|
3012
|
+
assert.equal(t.status, 'pending')
|
|
3013
|
+
assert.equal(t.retryCount, 1)
|
|
3014
|
+
const first = t.runs[t.runs.length - 1]
|
|
3015
|
+
assert.equal(first.outcome, 'timeout/error')
|
|
3016
|
+
assert.equal(first.noResume, true) // 子会话不可信 → 该 run 失去续跑资格
|
|
3017
|
+
// 重派:noResume → 不回空唤醒,直接 fresh spawn(回退路径)
|
|
3018
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
3019
|
+
assert.deepEqual(sent, []) // 零 sendMessage(不空唤醒)
|
|
3020
|
+
assert.deepEqual(spawned, ['spawn', 'spawn']) // 本轮恰好一次 fresh spawn
|
|
3021
|
+
assert.equal(t.status, 'in-progress')
|
|
3022
|
+
assert.equal(t.claimedBy, 'child-2')
|
|
3023
|
+
})
|
|
3024
|
+
|
|
3025
|
+
test('可续跑 Worker⑪(卡2⑤):workerContinuable=false → 续跑机制整体短路(零 sendMessage/零 interrupt),一次性 dispose 兜底逐字不变', async () => {
|
|
3026
|
+
const parent = { id: FULL_SID }
|
|
3027
|
+
const sent = []
|
|
3028
|
+
const interrupted = []
|
|
3029
|
+
let disposed = 0
|
|
3030
|
+
// 即使卡上留着「continuable + 超时失败」的 run,开关关掉也必须零续跑(门禁在开关,不在 run 标记)
|
|
3031
|
+
const t = mkTask({ id: 'r5', title: '回退开关卡', status: 'pending', runs: [{ role: 'worker', id: 'child-old', at: '2026-01-01T00:00:00.000Z', model: '', outcome: 'timeout/error', hardMin: 120, continuable: true }] })
|
|
3032
|
+
const board = Object.assign(mkBoard([t]), { workerContinuable: false })
|
|
3033
|
+
const h = mkContinuableDispatch(board, { rootForSession: () => parent }, {
|
|
3034
|
+
subagents: {
|
|
3035
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
3036
|
+
start: async () => ({ id: 'run-1', result: Promise.reject(new Error('boom')), dispose: async function () { disposed++ } }),
|
|
3037
|
+
startContinuable: async () => { throw new Error('开关关:不该走 continuable') },
|
|
3038
|
+
sendMessage: async (sender, targetId) => { sent.push(targetId); return 'msg' },
|
|
3039
|
+
interrupt: (id) => { interrupted.push(id) },
|
|
3040
|
+
},
|
|
3041
|
+
})
|
|
3042
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
3043
|
+
for (let i = 0; i < 4; i++) await flush()
|
|
3044
|
+
assert.deepEqual(sent, []) // 零冷复活
|
|
3045
|
+
assert.deepEqual(interrupted, []) // 零 interrupt(一次性路径只 dispose)
|
|
3046
|
+
assert.ok(disposed >= 1) // 旧兜底照旧(run.dispose)
|
|
3047
|
+
assert.equal(t.status, 'pending') // 失败结算语义不变
|
|
3048
|
+
assert.equal(t.retryCount, 1)
|
|
3049
|
+
assert.equal(h.runs['r5'], undefined)
|
|
3050
|
+
const last = t.runs[t.runs.length - 1]
|
|
3051
|
+
assert.equal(last.continuable, undefined) // 一次性 run 不留 continuable 标记 → 天然不可续跑
|
|
3052
|
+
assert.equal(last.outcome, 'timeout/error')
|
|
3053
|
+
})
|
|
3054
|
+
|
|
3055
|
+
// 子进程探针(卡2 续跑基线护栏):真建 v4 日志 → 真续跑 → 区分「续跑轮没产出(旧文本不得冒充交付物)」
|
|
3056
|
+
// 与「续跑轮真产出(正常推进 verifying)」。必须子进程:findRunLog 走 os.homedir() 且进程内首次调用即缓存。
|
|
3057
|
+
const PROBE_RESUME_BASELINE_SOURCE = `
|
|
3058
|
+
const fs = await import('node:fs')
|
|
3059
|
+
const path = await import('node:path')
|
|
3060
|
+
const zlib = await import('node:zlib')
|
|
3061
|
+
const { createDispatch } = await import('./lib/dispatch.mjs')
|
|
3062
|
+
const SID = 'session-test-0000-0000-000000000000'
|
|
3063
|
+
const childId = 'child-old'
|
|
3064
|
+
const logDir = path.join(process.env.HOME, '.dsh', 'sessions', 'b1', childId)
|
|
3065
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
3066
|
+
const logPath = path.join(logDir, 'session.v4.jsonl.zstd')
|
|
3067
|
+
function frame(text) { return zlib.zstdCompressSync(Buffer.from(JSON.stringify({ type: 'assistant/message', data: { message: { role: 'assistant', content: [{ type: 'text', text: text }] } } }) + '\\n')) }
|
|
3068
|
+
fs.writeFileSync(logPath, frame('## 开发描述\\n上一轮(被中断那次)的残留文本,绝不能被当成续跑轮的交付物'))
|
|
3069
|
+
const listeners = []
|
|
3070
|
+
const runs = {}
|
|
3071
|
+
const sent = []
|
|
3072
|
+
const ctx = {
|
|
3073
|
+
fs: {}, get: () => null, timer: null,
|
|
3074
|
+
effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
|
|
3075
|
+
on: function (n, f) { listeners.push(f); return function () {} },
|
|
3076
|
+
subagents: {
|
|
3077
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
3078
|
+
start: async () => ({ id: 'run-1', result: new Promise(() => {}), dispose: async () => {} }),
|
|
3079
|
+
startContinuable: async () => ({ childId: 'child-x', messageId: 'm' }),
|
|
3080
|
+
sendMessage: async (sender, target) => { sent.push(target); return 'm2' },
|
|
3081
|
+
interrupt: () => {},
|
|
3082
|
+
},
|
|
3083
|
+
}
|
|
3084
|
+
const t = { id: 'w9', title: '续跑基线卡', description: '', status: 'pending', priority: 'medium', tags: [], parentId: null, assignMode: 'auto', assignee: null, context: { instructions: '' }, acceptance: '', dependsOn: [], pipeline: 'full', claimedBy: null, claimedAt: null, createdAt: '2026-01-01T00:00:00Z', resolvedAt: null, history: [], messages: [], runs: [{ role: 'worker', id: childId, at: '2026-01-01T00:00:00Z', model: '', outcome: 'timeout/error', hardMin: 120, continuable: true }] }
|
|
3085
|
+
const board = { version: 11, ownerSession: SID, boardMode: 'auto', maxWorkers: 3, tasks: [t] }
|
|
3086
|
+
const d = createDispatch(ctx, { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: { [SID]: runs } }, {
|
|
3087
|
+
rt: async () => board, wt: async () => {}, mutateLocked: async (s, f) => f(board), kickCycle: () => {}, rootForSession: () => ({ id: SID }),
|
|
3088
|
+
withTimeout: () => new Promise(() => {}), runsFor: () => runs, feedbackOn: () => true, pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
3089
|
+
})
|
|
3090
|
+
const idle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)) }
|
|
3091
|
+
// 第一轮:冷复活后「一个字都没产出」(日志无新字节)→ 必须按失败重排,不许拿残留文本推进 verifying
|
|
3092
|
+
await d.poolCycle(SID)
|
|
3093
|
+
listeners[0]({ agent: { session: { id: childId } }, status: 'running' })
|
|
3094
|
+
listeners[0]({ agent: { session: { id: childId } }, status: 'idle' })
|
|
3095
|
+
await idle()
|
|
3096
|
+
const stale = { status: t.status, retryCount: t.retryCount || 0, deliverable: t.deliverable || null }
|
|
3097
|
+
// 第二轮:真追加一帧(续跑轮真产出)→ 正常推进 verifying,交付物取自**新**文本
|
|
3098
|
+
await d.poolCycle(SID)
|
|
3099
|
+
fs.appendFileSync(logPath, frame('## 开发描述\\n续跑轮的新交付物\\n## 自测情况\\nok'))
|
|
3100
|
+
listeners[0]({ agent: { session: { id: childId } }, status: 'running' })
|
|
3101
|
+
listeners[0]({ agent: { session: { id: childId } }, status: 'idle' })
|
|
3102
|
+
await idle()
|
|
3103
|
+
console.log(JSON.stringify({ stale: stale, ok: { status: t.status, deliverable: t.deliverable || null }, sent: sent }))
|
|
3104
|
+
`
|
|
3105
|
+
|
|
3106
|
+
test('可续跑 Worker⑫(卡2 续跑基线):续跑轮没产出 → 残留旧文本不得冒充交付物(按失败重排);真产出 → 正常推进 verifying', async () => {
|
|
3107
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tbc2-'))
|
|
3108
|
+
try {
|
|
3109
|
+
const child = spawnSync(process.execPath, ['-e', PROBE_RESUME_BASELINE_SOURCE], { env: Object.assign({}, process.env, { HOME: tmp, USERPROFILE: tmp }), encoding: 'utf8' })
|
|
3110
|
+
assert.equal(child.status, 0, 'probe 失败: ' + child.stderr)
|
|
3111
|
+
const out = JSON.parse(child.stdout.trim().split('\n').pop())
|
|
3112
|
+
assert.deepEqual(out.sent, ['child-old', 'child-old']) // 两轮都是真续跑(sendMessage 冷复活)
|
|
3113
|
+
// 第一轮:日志里明明有完整的分段格式文本,但那是上一轮的残留 → 只认基线之后的新字节 → 空文本按失败
|
|
3114
|
+
assert.equal(out.stale.status, 'pending')
|
|
3115
|
+
assert.equal(out.stale.retryCount, 1)
|
|
3116
|
+
assert.equal(out.stale.deliverable, null)
|
|
3117
|
+
// 第二轮:基线之后真追加了一帧 → 正常走文本通道推进 verifying,交付物取新文本
|
|
3118
|
+
assert.equal(out.ok.status, 'verifying')
|
|
3119
|
+
assert.match(out.ok.deliverable.summary, /续跑轮的新交付物/)
|
|
3120
|
+
assert.doesNotMatch(out.ok.deliverable.summary, /残留文本/)
|
|
3121
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
3122
|
+
})
|
|
3123
|
+
|
|
3124
|
+
test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run → interrupt 留存(零 dispose),任务回 pending 且刻意不留续跑资格', async () => {
|
|
3125
|
+
const parent = { id: FULL_SID }
|
|
3126
|
+
const calls = { interrupt: [] }
|
|
3127
|
+
const t = mkTask({ id: 'mt1', title: '手动终止卡', status: 'in-progress', claimedBy: 'child-live', runs: [{ role: 'worker', id: 'child-live', at: '2026-01-01T00:00:00.000Z', model: '', outcome: 'running', hardMin: 120, continuable: true }] })
|
|
3128
|
+
const board = mkBoard([t])
|
|
3129
|
+
// 活跃 rec:continuable 形态(无 run,只能 interrupt)
|
|
3130
|
+
const runs = { mt1: { id: 'child-live', childId: 'child-live', continuable: true, ran: true, run: null, role: 'worker', taskId: 'mt1', startedAt: Date.now(), model: '', settled: false } }
|
|
3131
|
+
const h = mkRpcHandlers(board, { rootForSession: () => parent, runsFor: () => runs }, { subagents: { interrupt: (id, auth) => calls.interrupt.push({ id: id, auth: auth }) } })
|
|
3132
|
+
const r = await h['terminate-agent']({ taskId: 'mt1' })
|
|
3133
|
+
assert.equal(r.ok, true); assert.equal(r.terminated, 'worker:mt1')
|
|
3134
|
+
// 终止 = interrupt 留存(不是 dispose):authority 形状同硬超时臂
|
|
3135
|
+
assert.deepEqual(calls.interrupt, [{ id: 'child-live', auth: { kind: 'ancestor', agent: parent } }])
|
|
3136
|
+
assert.equal(runs.mt1, undefined) // 活跃表摘除(结算通道随之关闭,不会被 idle 事件误结算)
|
|
3137
|
+
assert.equal(t.status, 'pending'); assert.equal(t.claimedBy, null) // 既有语义:重新排队
|
|
3138
|
+
// 刻意不留续跑资格:结局停在 running → resumeTarget 不认(重派走 fresh spawn,不唤醒刚被终止的子会话)
|
|
3139
|
+
assert.equal(t.runs[t.runs.length - 1].outcome, 'running')
|
|
3140
|
+
})
|
|
3141
|
+
|
|
2209
3142
|
test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
|
|
2210
3143
|
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
2211
3144
|
// spawnOneShot:hook 角色走 buildHookPrompt(phase 由 role 决定)
|
|
@@ -2401,8 +3334,9 @@ test('回执开关①:cfg/normalizeBoard/seed 三处缺省均为 true(老板
|
|
|
2401
3334
|
|
|
2402
3335
|
test('回执开关②:notifyDispatch=false → spawn 成功也不入派发回执(真跑 poolCycle + 真 spawnOneShot)', async () => {
|
|
2403
3336
|
const calls = []
|
|
2404
|
-
// 真 spawnOneShot 需要一个可用 provider:start
|
|
2405
|
-
|
|
3337
|
+
// 真 spawnOneShot 需要一个可用 provider:start/startContinuable 都返回永不结算的 run
|
|
3338
|
+
// (派发回执只看 spawn 成功,不看结局);两条路都 mock,用例才对「Worker 默认 continuable」无感。
|
|
3339
|
+
const ctxOver = { subagents: { list: () => ['p1'], getProvider: () => ({ inheritsParentContext: false }), start: async () => ({ id: 'run-1', dispose() {}, result: new Promise(function () {}) }), startContinuable: async () => ({ childId: 'child-1', messageId: 'msg-1' }) } }
|
|
2406
3340
|
function run(flag) {
|
|
2407
3341
|
const board = Object.assign(mkBoard([mkTask({ id: 'dt1', title: '被派的卡' })]), { maxWorkers: 3, notifyDispatch: flag })
|
|
2408
3342
|
return mkDispatch(board, {
|
|
@@ -2424,29 +3358,38 @@ test('回执开关②:notifyDispatch=false → spawn 成功也不入派发回
|
|
|
2424
3358
|
|
|
2425
3359
|
test('回执开关③b:notifyDone=false → worker 三连败转 blocked 也不入完成回执(真跑结算路径)', async () => {
|
|
2426
3360
|
const done = []
|
|
2427
|
-
// run 立即失败(Promise.reject)→ settleRun 走失败分支 → retryCount 2→3 → blocked
|
|
2428
|
-
|
|
2429
|
-
|
|
3361
|
+
// run 立即失败(Promise.reject)→ settleRun 走失败分支 → retryCount 2→3 → blocked(状态机不受开关影响)。
|
|
3362
|
+
// 两条 spawn 路都挂上立即失败的结局:Worker 默认走 continuable(spawnOneShot 里约定的
|
|
3363
|
+
// continuableResult 测试钩子),workerContinuable=false 时才落到一次性 run.result。
|
|
3364
|
+
const ctxOver = { subagents: { list: () => ['p1'], getProvider: () => ({ inheritsParentContext: false }), start: async () => ({ id: 'run-1', dispose() {}, result: Promise.reject(new Error('boom')) }), startContinuable: async () => ({ childId: 'child-1', messageId: 'msg-1' }) } }
|
|
3365
|
+
async function run(flag, continuable) {
|
|
2430
3366
|
done.length = 0
|
|
2431
3367
|
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 })
|
|
3368
|
+
const board = Object.assign(mkBoard([mkTask({ id: 'dt2', title: '会失败的卡', retryCount: 2 })]), { maxWorkers: 3, notifyDone: flag, workerContinuable: continuable })
|
|
2433
3369
|
const h = mkDispatch(board, {
|
|
2434
3370
|
rootForSession: () => ({ id: FULL_SID }),
|
|
2435
3371
|
runsFor: () => runs,
|
|
3372
|
+
// 测试钩子:continuable 没有 run.result,这里注入一个立即失败的结果
|
|
3373
|
+
// (生产不注入 → 永不落定,结算只走 agent/status 的 idle)
|
|
3374
|
+
continuableResult: () => Promise.reject(new Error('boom')),
|
|
2436
3375
|
notifyTaskDone: (sid, t, kind) => done.push({ id: t.id, kind: kind, status: t.status }),
|
|
2437
3376
|
}, ctxOver)
|
|
2438
3377
|
await h.dispatch.poolCycle(FULL_SID)
|
|
2439
3378
|
for (let i = 0; i < 3; i++) await new Promise(r => setImmediate(r)) // 结算链(含 closeRunHistory/usage)全在微任务里
|
|
2440
3379
|
return { h, board }
|
|
2441
3380
|
}
|
|
2442
|
-
//
|
|
2443
|
-
const off = await run(false)
|
|
3381
|
+
// 关(continuable 默认路径):照常 blocked(卡该阻塞就阻塞),只是一条完成回执都不发
|
|
3382
|
+
const off = await run(false, true)
|
|
2444
3383
|
assert.equal(off.board.tasks[0].status, 'blocked')
|
|
2445
3384
|
assert.deepEqual(done, [])
|
|
2446
3385
|
// 开:同一路径照常回执(证明闸门没误伤完成回执主通道)
|
|
2447
|
-
const on = await run(true)
|
|
3386
|
+
const on = await run(true, true)
|
|
2448
3387
|
assert.equal(on.board.tasks[0].status, 'blocked')
|
|
2449
3388
|
assert.deepEqual(done, [{ id: 'dt2', kind: 'blocked', status: 'blocked' }])
|
|
3389
|
+
// 回退路径(workerContinuable=false):一次性 run.result 结算语义逐字不变(同一断言恒成立)
|
|
3390
|
+
const legacy = await run(true, false)
|
|
3391
|
+
assert.equal(legacy.board.tasks[0].status, 'blocked')
|
|
3392
|
+
assert.deepEqual(done, [{ id: 'dt2', kind: 'blocked', status: 'blocked' }])
|
|
2450
3393
|
})
|
|
2451
3394
|
|
|
2452
3395
|
test('回执开关③/④:完成/阻塞回执闸门在结算调用方;歧义裁决通道零开关(源码级断言)', () => {
|
|
@@ -2476,12 +3419,13 @@ test('回执开关⑤:设置弹层「通知」小节两行开关 + set-board-c
|
|
|
2476
3419
|
assert.match(cli, /'通知'/)
|
|
2477
3420
|
assert.match(cli, /'⚡ 派发回执(任务被 Worker\/Verifier 领走时播报)'/)
|
|
2478
3421
|
assert.match(cli, /'✅ 完成回执(任务完成或阻塞时聚合播报)'/)
|
|
2479
|
-
|
|
2480
|
-
assert.match(cli, /checked: props\.
|
|
3422
|
+
// 开关行走乐观路径(task-muw5uudk:点击瞬时翻面,rpc 与失败回滚在 setCfg 内;键名分别断言)
|
|
3423
|
+
assert.match(cli, /checked: props\.notifyDispatch !== false, onChange: function \(e\) \{ setCfg\('notifyDispatch', e\.target\.checked\) \}/)
|
|
3424
|
+
assert.match(cli, /checked: props\.notifyDone !== false, onChange: function \(e\) \{ setCfg\('notifyDone', e\.target\.checked\) \}/)
|
|
2481
3425
|
assert.match(cli, /歧义裁决通知不受这两个开关影响/)
|
|
2482
3426
|
// 透传链路:state 读取(老 host 缺字段=开)→ TopPanel useState → PoolCfgPopover props
|
|
2483
|
-
assert.match(cli, /state\.notifyDispatch =
|
|
2484
|
-
assert.match(cli, /state\.notifyDone =
|
|
3427
|
+
assert.match(cli, /state\.notifyDispatch = cfgKnobOf\(d, 'notifyDispatch'\)/) // 读取口径单点定义(task-muw5uudk)
|
|
3428
|
+
assert.match(cli, /state\.notifyDone = cfgKnobOf\(d, 'notifyDone'\)/) // 读取口径单点定义(task-muw5uudk)
|
|
2485
3429
|
assert.match(cli, /useState\(state\.notifyDispatch\)/)
|
|
2486
3430
|
assert.match(cli, /feedbackEnabled: fbEnabled, notifyDispatch: ndOn, notifyDone: nnOn/)
|
|
2487
3431
|
// host 通道:set-board-config 白名单两键布尔原样存 + get-tasks 显式透出确定布尔值
|
|
@@ -2683,7 +3627,7 @@ function mkDispatch(board, over, ctxOver) {
|
|
|
2683
3627
|
const dispatch = createDispatch(
|
|
2684
3628
|
// 创建期无条件触达:effect(卸载清理注册)/ get('systemPrompt' 引导段);不给 timer/agents/subagents
|
|
2685
3629
|
Object.assign({ fs: {}, effect: function () {}, get: function () { return null } }, ctxOver || {}),
|
|
2686
|
-
{ knownSessions: {}, dispatchedEver: {}, badModels: {},
|
|
3630
|
+
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} },
|
|
2687
3631
|
Object.assign({
|
|
2688
3632
|
rt: async () => { counters.reads++; return board },
|
|
2689
3633
|
wt: async () => { counters.writes++ },
|
|
@@ -2937,7 +3881,7 @@ test('epicSplit 行为①:Team 提示词第 6 条随门禁注入/消失(真
|
|
|
2937
3881
|
feedbackOn: () => false, epicSplitOn: () => on,
|
|
2938
3882
|
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
2939
3883
|
})
|
|
2940
|
-
const mkState = () => { const s = { knownSessions: {}, dispatchedEver: {}, badModels: {},
|
|
3884
|
+
const mkState = () => { const s = { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} }; s.teamModeCache[SID] = true; return s }
|
|
2941
3885
|
const textOf = (on) => {
|
|
2942
3886
|
sections.length = 0
|
|
2943
3887
|
createDispatch(ctx, mkState(), mkDeps(on))
|
|
@@ -2989,6 +3933,20 @@ test('epicSplit 透出:get-tasks 返回确定布尔值(缺省 true / 显式
|
|
|
2989
3933
|
assert.equal((await h['get-tasks']({})).epicSplit, true)
|
|
2990
3934
|
})
|
|
2991
3935
|
|
|
3936
|
+
test('workerContinuable 透出:get-tasks 返回确定布尔值(缺省 true / 显式 false),set-board-config 白名单落盘', async () => {
|
|
3937
|
+
const board = mkBoard([])
|
|
3938
|
+
const h = mkRpcHandlers(board)
|
|
3939
|
+
assert.equal((await h['get-tasks']({})).workerContinuable, true) // 老看板缺字段 → true(新行为即默认)
|
|
3940
|
+
assert.equal((await h['set-board-config']({ key: 'workerContinuable', value: false })).ok, true)
|
|
3941
|
+
assert.equal(board.workerContinuable, false)
|
|
3942
|
+
assert.equal((await h['get-tasks']({})).workerContinuable, false) // 回退开关可读可写(客户端按需渲染开关)
|
|
3943
|
+
await h['set-board-config']({ key: 'workerContinuable', value: true })
|
|
3944
|
+
assert.equal((await h['get-tasks']({})).workerContinuable, true)
|
|
3945
|
+
// 脏值口径:非布尔落盘值在读路径被 cfg 归一为 true(与 notifyDispatch 同款)
|
|
3946
|
+
board.workerContinuable = 'no'
|
|
3947
|
+
assert.equal((await h['get-tasks']({})).workerContinuable, true)
|
|
3948
|
+
})
|
|
3949
|
+
|
|
2992
3950
|
test('epicSplit 行为:关掉后 create-task 不附 suggestSplit,但显式 parentId 建子卡 + 史诗自动收口照常(机制不禁)', async () => {
|
|
2993
3951
|
const board = mkBoard([])
|
|
2994
3952
|
const h = mkRpcHandlers(board)
|
|
@@ -3015,7 +3973,7 @@ test('epicSplit 接线(源码级):缓存同步 / Team 引导段门禁 /
|
|
|
3015
3973
|
// 配置层(core.mjs:cfg 兜底 / seed 初值 / normalizeBoard 老看板补齐)
|
|
3016
3974
|
assert.match(coreSrc, /epicSplit: d\.epicSplit !== false/)
|
|
3017
3975
|
assert.match(coreSrc, /if \(typeof d\.epicSplit !== 'boolean'\) d\.epicSplit = true/)
|
|
3018
|
-
assert.match(coreSrc, /notifyDone: true, epicSplit: true, minWorkers: 1/) // seed
|
|
3976
|
+
assert.match(coreSrc, /notifyDone: true, epicSplit: true, workerContinuable: true, minWorkers: 1/) // seed 缺省开(workerContinuable 随 task-muw5gnhv 插入 epicSplit 与 minWorkers 之间)
|
|
3019
3977
|
// 缓存链路:rt() 读盘同步 + set-board-config 当场回填(Team 提示词是同步组装,只能读缓存)
|
|
3020
3978
|
assert.match(src, /epicSplitCache\[sid\] = nd\.epicSplit !== false/)
|
|
3021
3979
|
assert.match(src, /function epicSplitOn\(sid\) \{ return epicSplitCache\[sid\] !== false \}/)
|
|
@@ -3030,9 +3988,9 @@ test('epicSplit 接线(源码级):缓存同步 / Team 引导段门禁 /
|
|
|
3030
3988
|
// ③ UI:入池配置弹层「功能」小节一行开关(勾选态缺字段=开)+ 状态透传链
|
|
3031
3989
|
assert.match(cli, /'功能'/)
|
|
3032
3990
|
assert.match(cli, /'🧩 史诗拆分:大任务引导拆为 epic \+ 子任务'/)
|
|
3033
|
-
assert.match(cli, /checked: props\.epicSplit !== false, onChange: function \(e\) \{
|
|
3991
|
+
assert.match(cli, /checked: props\.epicSplit !== false, onChange: function \(e\) \{ setCfg\('epicSplit', e\.target\.checked\) \}/)
|
|
3034
3992
|
assert.match(cli, /关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作/)
|
|
3035
|
-
assert.match(cli, /state\.epicSplit =
|
|
3993
|
+
assert.match(cli, /state\.epicSplit = cfgKnobOf\(d, 'epicSplit'\)/) // 读取口径单点定义(task-muw5uudk)
|
|
3036
3994
|
assert.match(cli, /useState\(state\.epicSplit\)/)
|
|
3037
3995
|
assert.match(cli, /epicSplit: esOn/)
|
|
3038
3996
|
})
|
|
@@ -3045,3 +4003,921 @@ test('README 双份同步记录 epicSplit 开关口径(关引导不禁机制
|
|
|
3045
4003
|
assert.ok(pkg.includes(s), 'README 应记录 epicSplit 口径:' + s)
|
|
3046
4004
|
}
|
|
3047
4005
|
})
|
|
4006
|
+
|
|
4007
|
+
// ===== 可续跑 Worker(task-muw5hhsh 卡3):usage 增量聚合(seq 水位线)/ 重启 reconcile / 文档与 UI 收尾 =====
|
|
4008
|
+
// 为什么必须真跑日志+真跑 poolCycle:卡3 三条 Step 都是「同一会话被结算多次」与「重启后内存表清零」这两类
|
|
4009
|
+
// 只在真实路径上才成立的形态——源码级断言看不出「第二次结算到底记了多少」,必须让 settleRun 真读真日志。
|
|
4010
|
+
// 公共 harness:临时 HOME(findRunLog 走 os.homedir(),子进程外改 env 无效)+ 真 spawnOneShot/真事件通道/
|
|
4011
|
+
// 真 usage 结算(mutateLocked 直写 board 对象)。childId 固定为 'child-old',与卡2 续跑用例同一形状。
|
|
4012
|
+
function frameOfSeq(events) {
|
|
4013
|
+
return zlib.zstdCompressSync(Buffer.from(events.map(function (e) { return JSON.stringify(e) }).join('\n') + '\n', 'utf8'))
|
|
4014
|
+
}
|
|
4015
|
+
// 一次助手 usage 事件(seq 显式给,模拟 v4 日志的全序字段)
|
|
4016
|
+
function usageEvent(seq, usage) { return { type: 'assistant/message', seq: seq, data: { usage: usage } } }
|
|
4017
|
+
// 临时会话日志根(deps.sessionsRoot 注入,不碰 os.homedir)——布局与真实一致:<root>/<bucket>/<childId>/session.v4.jsonl.zstd
|
|
4018
|
+
function mkLogRoot(bucket, childId, chunks) {
|
|
4019
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tbc3-'))
|
|
4020
|
+
const dir = path.join(root, bucket, childId)
|
|
4021
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
4022
|
+
const logPath = path.join(dir, 'session.v4.jsonl.zstd')
|
|
4023
|
+
if (chunks && chunks.length) fs.writeFileSync(logPath, Buffer.concat(chunks))
|
|
4024
|
+
return { root: root, logPath: logPath, append: function (b) { fs.appendFileSync(logPath, b) } }
|
|
4025
|
+
}
|
|
4026
|
+
function mkUsageDispatch(board, over, ctxOver) {
|
|
4027
|
+
const listeners = []
|
|
4028
|
+
const runs = {}
|
|
4029
|
+
const ctx = Object.assign({
|
|
4030
|
+
fs: {}, get: function () { return null }, timer: null,
|
|
4031
|
+
// 真 cordis 的 ctx.effect 立即执行回调取 disposer(订阅当场建立)——桩里必须照做,否则事件通道测不到
|
|
4032
|
+
effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
|
|
4033
|
+
on: function (n, f) { listeners.push(f); return function () {} },
|
|
4034
|
+
subagents: {
|
|
4035
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4036
|
+
start: async () => ({ id: 'run-1', result: new Promise(function () {}), dispose: async function () {} }),
|
|
4037
|
+
startContinuable: async () => ({ childId: 'child-old', messageId: 'msg-1' }),
|
|
4038
|
+
// 卡3① 要真走「重派续跑」路径(同一 childId 被结算两次)——续跑通道必须在位,
|
|
4039
|
+
// 否则第二轮会回落 fresh spawn(虽然 childId 相同、水量线仍生效,但断言的语义就不是续跑了)
|
|
4040
|
+
sendMessage: async () => 'msg-2',
|
|
4041
|
+
interrupt: () => {},
|
|
4042
|
+
},
|
|
4043
|
+
}, ctxOver || {})
|
|
4044
|
+
const dispatch = createDispatch(ctx, { knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: { [FULL_SID]: runs } }, Object.assign({
|
|
4045
|
+
rt: async () => board, wt: async () => {}, mutateLocked: async (sid, fn) => fn(board), kickCycle: () => {},
|
|
4046
|
+
rootForSession: () => ({ id: FULL_SID }),
|
|
4047
|
+
withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true, pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
4048
|
+
}, over || {}))
|
|
4049
|
+
return { dispatch, listeners, runs }
|
|
4050
|
+
}
|
|
4051
|
+
const fireIdle = async (h, childId) => {
|
|
4052
|
+
h.listeners[0]({ agent: { session: { id: childId } }, status: 'running' })
|
|
4053
|
+
h.listeners[0]({ agent: { session: { id: childId } }, status: 'idle' })
|
|
4054
|
+
await flush(); await flush(); await flush()
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
test('卡3①:usage 增量聚合——同一持久子会话两次结算只记增量(总量=两次增量之和,非双倍)', async () => {
|
|
4058
|
+
// 第 1 轮(首派):seq 0=模型,1=usage 10
|
|
4059
|
+
const L = mkLogRoot('b1', 'child-old', [frameOfSeq([{ type: 'request/context', seq: 0, data: { model: 'deepseek-flash' } }, usageEvent(1, { inputTokens: 10, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 10 })])])
|
|
4060
|
+
try {
|
|
4061
|
+
const t = mkTask({ id: 'u1', title: '增量账卡', status: 'pending' })
|
|
4062
|
+
const board = mkBoard([t])
|
|
4063
|
+
const h = mkUsageDispatch(board, { sessionsRoot: L.root })
|
|
4064
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4065
|
+
await fireIdle(h, 'child-old') // 首轮结算(本用例环境无子会话助手文本 → 走「空文本按失败」的既有降级臂)
|
|
4066
|
+
// 首轮:增量=10(水位线从 0 起 → 全量),水位线落卡 = maxSeq(1)
|
|
4067
|
+
assert.equal(t.usage.total, 10)
|
|
4068
|
+
assert.equal(h.runs['u1'], undefined)
|
|
4069
|
+
const first = t.runs[t.runs.length - 1]
|
|
4070
|
+
assert.equal(first.usageSeq, 1) // 水位线(下一轮从这之后继续)
|
|
4071
|
+
assert.equal(first.usage.total, 10)
|
|
4072
|
+
// 第 2 轮:重派续跑(同一 childId)→ 追加 seq 2=usage 5,结算只认增量 5
|
|
4073
|
+
L.append(frameOfSeq([usageEvent(2, { inputTokens: 3, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 5 })]))
|
|
4074
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4075
|
+
await fireIdle(h, 'child-old')
|
|
4076
|
+
// 断言①:总量 = 10 + 5 = 15(不是全量重复累加的 25);水位线推进到 2
|
|
4077
|
+
assert.equal(t.usage.total, 15)
|
|
4078
|
+
assert.equal(t.usage.input, 13) // 10 + 3
|
|
4079
|
+
assert.equal(t.usage.output, 2)
|
|
4080
|
+
assert.equal(t.usage.runs, 2) // runs 计数是「结算次数」,不受水位线影响
|
|
4081
|
+
const second = t.runs[t.runs.length - 1]
|
|
4082
|
+
assert.equal(second.resume, true) // 第二轮确实是续跑(沿用同一会话) assert.equal(second.usageSeq, 2)
|
|
4083
|
+
assert.equal(second.usage.total, 5) // run 级留账只记本轮增量
|
|
4084
|
+
// 第 3 轮:本轮零新增量 → 一行都不记(不记 0,也不重复记)
|
|
4085
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4086
|
+
await fireIdle(h, 'child-old')
|
|
4087
|
+
assert.equal(t.usage.total, 15) // 总量不动(拿不到增量就跳过累加)
|
|
4088
|
+
assert.equal(t.usage.runs, 2)
|
|
4089
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
4090
|
+
})
|
|
4091
|
+
|
|
4092
|
+
test('卡3④(基线):one-shot 路径计账不变——整份日志全量累加一次,与改造前逐字一致', async () => {
|
|
4093
|
+
const L = mkLogRoot('b1', 'run-1', [frameOfSeq([
|
|
4094
|
+
usageEvent(1, { inputTokens: 4, outputTokens: 5, cacheReadTokens: 100, cacheWriteTokens: 0, totalTokens: 109 }),
|
|
4095
|
+
usageEvent(2, { inputTokens: 6, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 8, totalTokens: 21 })
|
|
4096
|
+
])])
|
|
4097
|
+
try {
|
|
4098
|
+
// workerContinuable=false → 走一次性 start(run.id='run-1')→ rec 无 continuable/无 childId
|
|
4099
|
+
const t = mkTask({ id: 'o1', title: '一次性账卡', status: 'pending' })
|
|
4100
|
+
const board = Object.assign(mkBoard([t]), { workerContinuable: false })
|
|
4101
|
+
const h = mkUsageDispatch(board, { sessionsRoot: L.root }, {
|
|
4102
|
+
subagents: {
|
|
4103
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4104
|
+
start: async () => ({ id: 'run-1', result: Promise.reject(new Error('boom')), dispose: async function () {} }),
|
|
4105
|
+
startContinuable: async () => { throw new Error('开关关:不该走 continuable') },
|
|
4106
|
+
},
|
|
4107
|
+
})
|
|
4108
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4109
|
+
for (let i = 0; i < 4; i++) await flush()
|
|
4110
|
+
// 全量一次:109 + 21 = 130;五分量逐项累加(缓存读单列)
|
|
4111
|
+
assert.equal(t.usage.total, 130)
|
|
4112
|
+
assert.equal(t.usage.input, 10)
|
|
4113
|
+
assert.equal(t.usage.output, 12)
|
|
4114
|
+
assert.equal(t.usage.cacheRead, 100)
|
|
4115
|
+
assert.equal(t.usage.cacheWrite, 8)
|
|
4116
|
+
assert.equal(t.usage.runs, 1)
|
|
4117
|
+
// 水位线同样落卡(一次性 run 的日志只被结算这一次,水位线只是留痕,不改变任何口径)
|
|
4118
|
+
assert.equal(t.runs[t.runs.length - 1].usageSeq, 2)
|
|
4119
|
+
assert.equal(t.runs[t.runs.length - 1].continuable, undefined)
|
|
4120
|
+
// 一次性 run 不会被 reconcile 抢去做续跑(continuable 标记是唯一门禁)
|
|
4121
|
+
assert.equal(!!t.runs[t.runs.length - 1].continuable, false)
|
|
4122
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
4123
|
+
})
|
|
4124
|
+
|
|
4125
|
+
// ===== 可续跑 Worker⑭(task-muwkhqf8 回归修复):continuable 结算补关 run 记录(结局/usage/超时臂三件套)=====
|
|
4126
|
+
// 病根:continuable Worker 的 run 记录此前只有事件通道(agent/status running→idle → settleContinuable →
|
|
4127
|
+
// settleRun)会收尾。Worker 用 board_report 上报完成时任务已被工具通道推进到 verifying,若该 turn 的
|
|
4128
|
+
// idle 事件没被观测到(伪 idle 被 rec.ran 守卫挡下/冷窗口/重启/事件丢失),run 记录永久停在 outcome='running'
|
|
4129
|
+
// → ① 软超时臂对已完成 run 误报 ② usage 漏计 ③ 硬超时臂到期对已结算的闲置会话补枪。
|
|
4130
|
+
// 观察口径(活体形状,非源码断言):真跑 poolCycle(真 spawnOneShot)+ 真 board_report 工具(execute)
|
|
4131
|
+
// + 真 usage 结算(真读 zstd 日志 + seq 水位线)+ mock timer 断言软臂被真清掉。
|
|
4132
|
+
// 可注入的 mock timer:与真实 ctx.timer 的**回调式**形态同构(timeout(cb, ms) 返回 dispose 闭包),
|
|
4133
|
+
// 记下每个 handle 是否被清 → 「超时臂摘除」在单测里可断言(promise 式桩做不到这点)。
|
|
4134
|
+
// interval 是必须项:createDispatch 会挂 15s 心跳(返回 disposer),缺了直接 TypeError。
|
|
4135
|
+
function mkTimerProbe() {
|
|
4136
|
+
const handles = []
|
|
4137
|
+
return {
|
|
4138
|
+
handles,
|
|
4139
|
+
timer: {
|
|
4140
|
+
timeout: function (cb, ms) {
|
|
4141
|
+
const h = { ms: ms, cancelled: false }
|
|
4142
|
+
handles.push(h)
|
|
4143
|
+
return function () { h.cancelled = true }
|
|
4144
|
+
},
|
|
4145
|
+
interval: function () { return function () {} },
|
|
4146
|
+
},
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
function lastRun(t) { return t.runs[t.runs.length - 1] }
|
|
4150
|
+
|
|
4151
|
+
test('可续跑 Worker⑭a(task-muwkhqf8):board_report 完成 → runs[] 条目关账(completed+endedAt+usage 落账+usageRecorded)+ 软超时臂被摘除', async () => {
|
|
4152
|
+
// 真日志:seq 0=模型,1=usage 33 → 增量口径落账 33(首轮水位线 0 = 全量)
|
|
4153
|
+
// 桶名/childId 每条用例独占(node --test 内文件用例可并行,共用同一 root 会互相踩日志目录)
|
|
4154
|
+
const L = mkLogRoot('b-cu1', 'child-cua', [frameOfSeq([
|
|
4155
|
+
{ type: 'request/context', seq: 0, data: { model: 'deepseek-flash' } },
|
|
4156
|
+
usageEvent(1, { inputTokens: 20, outputTokens: 10, cacheReadTokens: 3, cacheWriteTokens: 0, totalTokens: 33 }),
|
|
4157
|
+
])])
|
|
4158
|
+
const TP = mkTimerProbe()
|
|
4159
|
+
try {
|
|
4160
|
+
const t = mkTask({ id: 'cu1', title: 'continuable 回归卡', status: 'pending' })
|
|
4161
|
+
const board = Object.assign(mkBoard([t]), { hardTimeoutMin: 360, softTimeoutMin: 30 })
|
|
4162
|
+
const h = mkUsageDispatch(board, { sessionsRoot: L.root }, {
|
|
4163
|
+
timer: TP.timer,
|
|
4164
|
+
subagents: {
|
|
4165
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4166
|
+
start: async () => ({ id: 'run-1', result: new Promise(function () {}), dispose: async function () {} }),
|
|
4167
|
+
startContinuable: async () => ({ childId: 'child-cua', messageId: 'msg-1' }),
|
|
4168
|
+
sendMessage: async () => 'msg-2', interrupt: () => {},
|
|
4169
|
+
},
|
|
4170
|
+
})
|
|
4171
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4172
|
+
assert.equal(t.status, 'in-progress')
|
|
4173
|
+
assert.equal(h.runs['cu1'].continuable, true)
|
|
4174
|
+
// 派发即挂两级超时臂:软臂至少一个 handle(硬臂由 withTimeout 提供,桩里是恒等透传)
|
|
4175
|
+
assert.ok(TP.handles.length >= 1, '派发必须挂上软超时臂')
|
|
4176
|
+
// Worker 真调 board_report(工具通道)上报完成
|
|
4177
|
+
const r = await mkRpcHandlers(board, { reportRunSettled: h.dispatch.settleReportedRun, runsFor: () => h.runs }).__tools['board_report'].execute(
|
|
4178
|
+
{ taskId: 'cu1', kind: 'complete', summary: '做完了', changes: '改了 a.mjs', selfTest: 'npm test 全绿', diffStat: '+1 -1' }, {})
|
|
4179
|
+
assert.equal(r.ok, true)
|
|
4180
|
+
assert.equal(t.status, 'verifying')
|
|
4181
|
+
// 断言①:run 记录关账——结局 completed + endedAt 落 + usage 落账(增量口径)+ 幂等旗
|
|
4182
|
+
const run = lastRun(t)
|
|
4183
|
+
assert.equal(run.id, 'child-cua')
|
|
4184
|
+
assert.equal(run.outcome, 'completed')
|
|
4185
|
+
assert.ok(run.endedAt, 'outcome 关账必须同时落 endedAt')
|
|
4186
|
+
assert.equal(run.usage.total, 33)
|
|
4187
|
+
assert.equal(run.usage.input, 20)
|
|
4188
|
+
assert.equal(run.usage.cacheRead, 3)
|
|
4189
|
+
assert.equal(run.usageRecorded, true) // 幂等旗:重复结算不二次记账
|
|
4190
|
+
assert.equal(run.usageSeq, 1) // 水位线落卡(续跑轮从此继续)
|
|
4191
|
+
// 任务级 usage 同步累加 + 活跃表项摘除(任务已落定,不该再占 Worker 并发位)
|
|
4192
|
+
assert.equal(t.usage.total, 33)
|
|
4193
|
+
assert.equal(t.usage.runs, 1)
|
|
4194
|
+
assert.equal(h.runs['cu1'], undefined)
|
|
4195
|
+
// 断言②:两级超时臂被摘除——软臂 handle 收到 dispose
|
|
4196
|
+
assert.ok(TP.handles.every(x => x.cancelled), '结算后软超时臂必须被真清掉(否则每 30min 刷假告警)')
|
|
4197
|
+
// 断言③:重复上报(工具入口重入)不二次关账——任务已不在 in-progress,run 结局/endedAt/usage 均不动
|
|
4198
|
+
const endedAt = run.endedAt
|
|
4199
|
+
const r2 = await mkRpcHandlers(board, { reportRunSettled: h.dispatch.settleReportedRun, runsFor: () => h.runs }).__tools['board_report'].execute(
|
|
4200
|
+
{ taskId: 'cu1', kind: 'complete', summary: '再报一次', changes: '', selfTest: '', diffStat: '' }, {})
|
|
4201
|
+
assert.equal(r2.ok, false) // 工具门禁拒绝(not in-progress)
|
|
4202
|
+
assert.equal(lastRun(t).endedAt, endedAt)
|
|
4203
|
+
assert.equal(t.usage.total, 33) // usage 不二次累加
|
|
4204
|
+
assert.equal(t.usage.runs, 1)
|
|
4205
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
4206
|
+
})
|
|
4207
|
+
|
|
4208
|
+
test('可续跑 Worker⑭b(task-muwkhqf8):文本兜底入口(agent/status idle)同断言——completed+endedAt+usage 落账+超时臂摘除', async () => {
|
|
4209
|
+
// 真日志:同一份日志里既有**助手文本**(文本通道据此推进 verifying)又有 usage 帧
|
|
4210
|
+
const L = mkLogRoot('b-cu2', 'child-cub', [frameOfSeq([
|
|
4211
|
+
{ type: 'request/context', seq: 0, data: { model: 'deepseek-flash' } },
|
|
4212
|
+
{ type: 'assistant/message', seq: 1, data: { message: { role: 'assistant', content: [{ type: 'text', text: '## 开发描述\n文本通道完工\n## 自测情况\nok' }] } } },
|
|
4213
|
+
usageEvent(2, { inputTokens: 7, outputTokens: 3, cacheReadTokens: 1, cacheWriteTokens: 0, totalTokens: 11 }),
|
|
4214
|
+
])])
|
|
4215
|
+
const TP = mkTimerProbe()
|
|
4216
|
+
try {
|
|
4217
|
+
const t = mkTask({ id: 'cu2', title: '文本兜底卡', status: 'pending' })
|
|
4218
|
+
const board = Object.assign(mkBoard([t]), { hardTimeoutMin: 360, softTimeoutMin: 30 })
|
|
4219
|
+
const h = mkUsageDispatch(board, { sessionsRoot: L.root }, {
|
|
4220
|
+
timer: TP.timer,
|
|
4221
|
+
subagents: {
|
|
4222
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4223
|
+
start: async () => ({ id: 'run-1', result: new Promise(function () {}), dispose: async function () {} }),
|
|
4224
|
+
startContinuable: async () => ({ childId: 'child-cub', messageId: 'msg-1' }),
|
|
4225
|
+
sendMessage: async () => 'msg-2', interrupt: () => {},
|
|
4226
|
+
},
|
|
4227
|
+
})
|
|
4228
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4229
|
+
await fireIdle(h, 'child-cub') // running→idle:事件通道结算(文本兜底)
|
|
4230
|
+
assert.equal(t.status, 'verifying') // 有助手文本 → 推进验收(不是「空文本按失败」)
|
|
4231
|
+
const run = lastRun(t)
|
|
4232
|
+
assert.equal(run.outcome, 'completed') // 与一次性路径收尾口径一致
|
|
4233
|
+
assert.ok(run.endedAt)
|
|
4234
|
+
assert.equal(run.usage.total, 11)
|
|
4235
|
+
assert.equal(run.usageRecorded, true)
|
|
4236
|
+
assert.equal(t.usage.total, 11)
|
|
4237
|
+
assert.ok(TP.handles.every(x => x.cancelled), '文本兜底结算同样摘除超时臂')
|
|
4238
|
+
assert.equal(h.runs['cu2'], undefined)
|
|
4239
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
4240
|
+
})
|
|
4241
|
+
|
|
4242
|
+
test('可续跑 Worker⑭c(task-muwkhqf8 回归):上报与事件双通道先后到达 → usage 只记一笔、run 结局不被二次改写(幂等)', async () => {
|
|
4243
|
+
const L = mkLogRoot('b-cu3', 'child-cuc', [frameOfSeq([
|
|
4244
|
+
{ type: 'request/context', seq: 0, data: { model: 'deepseek-flash' } },
|
|
4245
|
+
{ type: 'assistant/message', seq: 1, data: { message: { role: 'assistant', content: [{ type: 'text', text: '## 开发描述\n工具通道完工\n## 自测情况\nok' }] } } },
|
|
4246
|
+
usageEvent(2, { inputTokens: 7, outputTokens: 3, cacheReadTokens: 1, cacheWriteTokens: 0, totalTokens: 11 }),
|
|
4247
|
+
])])
|
|
4248
|
+
const TP = mkTimerProbe()
|
|
4249
|
+
try {
|
|
4250
|
+
const t = mkTask({ id: 'cu3', title: '双通道卡', status: 'pending' })
|
|
4251
|
+
const board = Object.assign(mkBoard([t]), { hardTimeoutMin: 360, softTimeoutMin: 30 })
|
|
4252
|
+
const h = mkUsageDispatch(board, { sessionsRoot: L.root }, {
|
|
4253
|
+
timer: TP.timer,
|
|
4254
|
+
subagents: {
|
|
4255
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4256
|
+
start: async () => ({ id: 'run-1', result: new Promise(function () {}), dispose: async function () {} }),
|
|
4257
|
+
startContinuable: async () => ({ childId: 'child-cuc', messageId: 'msg-1' }),
|
|
4258
|
+
sendMessage: async () => 'msg-2', interrupt: () => {},
|
|
4259
|
+
},
|
|
4260
|
+
})
|
|
4261
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4262
|
+
const r = await mkRpcHandlers(board, { reportRunSettled: h.dispatch.settleReportedRun, runsFor: () => h.runs }).__tools['board_report'].execute(
|
|
4263
|
+
{ taskId: 'cu3', kind: 'complete', summary: '完成', changes: '', selfTest: '', diffStat: '' }, {})
|
|
4264
|
+
assert.equal(r.ok, true)
|
|
4265
|
+
const endedAt = lastRun(t).endedAt
|
|
4266
|
+
// 上报收尾后 idle 事件才到(rec 已摘除 + rec.settled 已立)→ 不二次关账、不二次记账
|
|
4267
|
+
h.listeners[0]({ agent: { session: { id: 'child-cuc' } }, status: 'running' })
|
|
4268
|
+
h.listeners[0]({ agent: { session: { id: 'child-cuc' } }, status: 'idle' })
|
|
4269
|
+
await flush(); await flush(); await flush()
|
|
4270
|
+
assert.equal(t.status, 'verifying') // 状态不被事件通道回退
|
|
4271
|
+
assert.equal(lastRun(t).outcome, 'completed') // 结局不被改写
|
|
4272
|
+
assert.equal(lastRun(t).endedAt, endedAt) // endedAt 不刷新
|
|
4273
|
+
assert.equal(t.usage.total, 11) // usage 只有一笔
|
|
4274
|
+
assert.equal(t.usage.runs, 1)
|
|
4275
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
4276
|
+
})
|
|
4277
|
+
|
|
4278
|
+
test('卡3②③:重启 reconcile——存活的续跑 Worker 被找回重挂观测;已死的会话走硬超时等价物回 pending', async () => {
|
|
4279
|
+
const queried = []
|
|
4280
|
+
const sent = []
|
|
4281
|
+
const spawned = []
|
|
4282
|
+
const found = mkTask({
|
|
4283
|
+
id: 'k1', title: '重启后仍在跑的卡', status: 'in-progress', claimedBy: 'child-alive', claimedAt: '2026-01-01T00:00:00Z',
|
|
4284
|
+
runs: [{ role: 'worker', id: 'child-alive', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running', hardMin: 120, continuable: true }],
|
|
4285
|
+
})
|
|
4286
|
+
const dead = mkTask({
|
|
4287
|
+
id: 'k2', title: '重启后会话已死的卡', status: 'in-progress', claimedBy: 'child-dead', claimedAt: '2026-01-01T00:00:00Z',
|
|
4288
|
+
runs: [{ role: 'worker', id: 'child-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running', hardMin: 120, continuable: true }],
|
|
4289
|
+
})
|
|
4290
|
+
// 干扰项:一次性 run(不可续跑——continuable 标记是唯一门禁)/ 结局已落 timeout-error(归重派续跑路径,reconcile 不抢)
|
|
4291
|
+
// claimedAt 取「刚刚」(本用例只测 reconcile,不想顺带触发孤儿回收——那会让干扰项被回收重排,混淆观察口径)
|
|
4292
|
+
const nowIso = new Date().toISOString()
|
|
4293
|
+
const oneShot = mkTask({
|
|
4294
|
+
id: 'k3', title: '一次性 run 卡', status: 'in-progress', claimedBy: 'child-oneshot', claimedAt: nowIso,
|
|
4295
|
+
runs: [{ role: 'worker', id: 'child-oneshot', at: nowIso, model: '', outcome: 'running', hardMin: 120 }],
|
|
4296
|
+
})
|
|
4297
|
+
const failed = mkTask({
|
|
4298
|
+
id: 'k4', title: '已落超时结局的卡', status: 'in-progress', claimedBy: 'child-failed', claimedAt: nowIso,
|
|
4299
|
+
runs: [{ role: 'worker', id: 'child-failed', at: nowIso, model: '', outcome: 'timeout/error', hardMin: 120, continuable: true }],
|
|
4300
|
+
})
|
|
4301
|
+
const board = mkBoard([found, dead, oneShot, failed])
|
|
4302
|
+
const h = mkUsageDispatch(board, { rootForSession: () => ({ id: FULL_SID }) }, {
|
|
4303
|
+
subagents: {
|
|
4304
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4305
|
+
start: async () => { spawned.push('one-shot'); return { id: 'run-1', result: new Promise(function () {}), dispose: async function () {} } },
|
|
4306
|
+
startContinuable: async () => { spawned.push('continuable'); return { childId: 'child-new', messageId: 'm' } },
|
|
4307
|
+
// 死会话的冷复活真实失败(NOT_RESUMABLE):reconcile 之后本轮重派会先试续跑再回落 fresh spawn
|
|
4308
|
+
sendMessage: async (sender, targetId) => { sent.push(targetId); throw new Error('subagent/not-resumable') },
|
|
4309
|
+
interrupt: () => {},
|
|
4310
|
+
listChildren: async (sid) => { queried.push(sid); return [{ sessionId: 'child-alive', label: 'worker:k1' }] },
|
|
4311
|
+
},
|
|
4312
|
+
})
|
|
4313
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4314
|
+
// ② 找回存活 run:rec 重建(与 spawnOneShot 的 continuable 形态同构)+ 重挂超时臂 → 任务留在进行中
|
|
4315
|
+
assert.deepEqual(queried, [FULL_SID]) // 查的是 root 会话的 children
|
|
4316
|
+
const rec = h.runs['k1']
|
|
4317
|
+
assert.ok(rec, '存活子会话必须被重新挂回活跃表')
|
|
4318
|
+
assert.equal(rec.id, 'child-alive'); assert.equal(rec.childId, 'child-alive')
|
|
4319
|
+
assert.equal(rec.continuable, true); assert.equal(rec.ran, false); assert.equal(rec.settled, false)
|
|
4320
|
+
assert.equal(rec.restored, true) // 「重启找回」与首次 spawn 可区分
|
|
4321
|
+
assert.equal(typeof rec.baselineBytes, 'number') // 基线取当前日志字节数(重启前产出不冒充本轮交付物)
|
|
4322
|
+
assert.equal(typeof rec.startedAt, 'number')
|
|
4323
|
+
assert.equal(found.status, 'in-progress'); assert.equal(found.claimedBy, 'child-alive')
|
|
4324
|
+
// ③ 会话已死 → 硬超时等价物:回 pending + 清占位 + 落 timeout/error 结局 + 留一行流转记录
|
|
4325
|
+
assert.equal(dead.runs[0].outcome, 'timeout/error')
|
|
4326
|
+
assert.ok(dead.runs[0].endedAt, '死会话的 run 结局要带 endedAt')
|
|
4327
|
+
const deadNote = dead.history.filter((x) => /重启后执行会话已丢失/.test(x.note || ''))
|
|
4328
|
+
assert.equal(deadNote.length, 1)
|
|
4329
|
+
assert.equal(deadNote[0].to, 'pending')
|
|
4330
|
+
assert.equal(dead.lastError, 'host 重启后 Worker 子会话已不存在(reconcile 未找回),任务回收重排')
|
|
4331
|
+
// 死会话当轮即被重新派发:先试冷复活(失败)→ 回落全新 Worker(reconcile 只做一次,不阻塞重派);
|
|
4332
|
+
// k1(已找回)占住 Worker 并发位,故本轮 spawned 只有 k2 的替代会话
|
|
4333
|
+
assert.deepEqual(sent, ['child-dead'])
|
|
4334
|
+
assert.deepEqual(spawned, ['continuable'])
|
|
4335
|
+
assert.equal(dead.status, 'in-progress'); assert.equal(dead.claimedBy, 'child-new')
|
|
4336
|
+
// 干扰项零变化(reconcile 不越界):一次性 run 与已落结局的卡都不进 reconcile,也不被它重置
|
|
4337
|
+
assert.equal(h.runs['k3'], undefined); assert.equal(oneShot.status, 'in-progress')
|
|
4338
|
+
assert.equal(oneShot.claimedBy, 'child-oneshot'); assert.equal(oneShot.runs.length, 1)
|
|
4339
|
+
assert.equal(failed.status, 'in-progress'); assert.equal(failed.claimedBy, 'child-failed')
|
|
4340
|
+
assert.equal(failed.runs[0].outcome, 'timeout/error'); assert.equal(h.runs['k4'], undefined)
|
|
4341
|
+
assert.equal(sent.length, 1) // 只有死会话那一次续跑尝试(k3/k4 零 sendMessage)
|
|
4342
|
+
// 只做一次(per host 生命周期):第二轮 listChildren 不再被调用,已找回的 rec 也不被重置
|
|
4343
|
+
const before = h.runs['k1']
|
|
4344
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4345
|
+
assert.equal(queried.length, 1)
|
|
4346
|
+
assert.equal(h.runs['k1'], before)
|
|
4347
|
+
})
|
|
4348
|
+
|
|
4349
|
+
test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次性 + 详情页 ↻ 续跑标注 + 文档收尾', () => {
|
|
4350
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
4351
|
+
const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
|
|
4352
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
4353
|
+
// ① usage 增量:readRunUsage 收第三参 sinceSeq,只认水位线之后的帧,并回传全份 maxSeq
|
|
4354
|
+
assert.match(usageSrc, /export function readRunUsage\(runId, sessionsRoot, sinceSeq\) \{/)
|
|
4355
|
+
assert.match(usageSrc, /if \(from > 0 && sq <= from\) continue/)
|
|
4356
|
+
assert.match(usageSrc, /maxSeq: -1/)
|
|
4357
|
+
// ② 结算侧:水位线从卡上 runs 条目读、增量累加后写回(只增不减)
|
|
4358
|
+
assert.match(dsp, /function seekSeqOf\(snap, taskId, runId\) \{/)
|
|
4359
|
+
assert.match(dsp, /since = seekSeqOf\(snap0, rec\.taskId, rec\.id\)/)
|
|
4360
|
+
assert.match(dsp, /u = readRunUsage\(String\(rec\.id\), sessionsRoot, since\)/)
|
|
4361
|
+
// 同 id 的续跑新条目尚未结算(usageSeq 未设)→ 必须继续往前找已结算的那条,否则水位线恒 0(踩过的坑)
|
|
4362
|
+
assert.match(dsp, /if \(!r \|\| String\(r\.id\) !== String\(runId\)\) continue\s*\n\s*var w = num0\(r\.usageSeq\)\s*\n\s*if \(w > 0\) return w/)
|
|
4363
|
+
assert.match(dsp, /t\.runs\[ri\]\.usageSeq = Math\.max\(num0\(t\.runs\[ri\]\.usageSeq\), num0\(u\.maxSeq\)\)/)
|
|
4364
|
+
// ③ reconcile:per-host 一次性标记 + listChildren 存活判定 + 重建 rec/重挂超时臂 + 死会话回 pending
|
|
4365
|
+
assert.match(dsp, /async function reconcileRoot\(sid\) \{/)
|
|
4366
|
+
assert.match(dsp, /if \(state\.reconcileDone\[sid\]\) return/)
|
|
4367
|
+
assert.match(dsp, /typeof subagents\.listChildren !== 'function'/)
|
|
4368
|
+
assert.match(dsp, /await withTimeout\(q, 5000, 'reconcile listChildren'\)/)
|
|
4369
|
+
assert.match(dsp, /restored: true, ran: false/)
|
|
4370
|
+
assert.match(dsp, /baselineBytes: logSizeOf\(childId\)/)
|
|
4371
|
+
assert.match(dsp, /armTimeouts\(sid, rec, tc, c2\)/)
|
|
4372
|
+
assert.match(dsp, /last\.outcome = 'timeout\/error'; last\.endedAt = new Date\(\)\.toISOString\(\)/)
|
|
4373
|
+
assert.match(dsp, /续跑观测已恢复/)
|
|
4374
|
+
// poolCycle 入口接线(在读 runs/snap 之前,重建的 rec 参与本轮活跃计数)
|
|
4375
|
+
assert.match(dsp, /try \{ await reconcileRoot\(sid\) \} catch \(e\) \{ console\.error\('\[task-board\] reconcile 失败:'/)
|
|
4376
|
+
// ④ 详情页 ↻ 续跑标注(读卡2 落的 runs[i].resume,不猜;老留档零渲染)
|
|
4377
|
+
assert.match(cli, /var rsMark = r\.resume === true \? ' ↻' : ''/)
|
|
4378
|
+
assert.match(cli, /↻ 冷复活续跑:沿用上一次的子会话,非新开/)
|
|
4379
|
+
// ⑤ index.mjs 共享 state 建 reconcileDone 容器(模块间显式注入,不隐式引用)
|
|
4380
|
+
const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
|
|
4381
|
+
assert.match(idx, /reconcileDone: \{\},\s+\/\/ sid -> true/)
|
|
4382
|
+
})
|
|
4383
|
+
|
|
4384
|
+
test('README 双份同步记录可续跑 Worker 三件套(continuable / 重启 reconcile / usage 增量)', () => {
|
|
4385
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
4386
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
4387
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束;断言⑤)
|
|
4388
|
+
for (const s of [
|
|
4389
|
+
'### 可续跑 Worker(continuable', // 新增章节
|
|
4390
|
+
'`workerContinuable`', // 回退开关
|
|
4391
|
+
'interrupt 留存(不销毁)', // 超时留存
|
|
4392
|
+
'重派冷复活续跑', // 冷复活续跑
|
|
4393
|
+
'重启 reconcile', // 重启找回
|
|
4394
|
+
'usageSeq', // usage 增量水位线
|
|
4395
|
+
'续跑基线', // 不拿旧文本冒充交付物
|
|
4396
|
+
'Verifier 与 hooks', // 一次性边界
|
|
4397
|
+
]) {
|
|
4398
|
+
assert.ok(pkg.includes(s), 'README 应记录可续跑 Worker 口径:' + s)
|
|
4399
|
+
}
|
|
4400
|
+
})
|
|
4401
|
+
|
|
4402
|
+
// ===== 设置开关点击即时反馈(task-muw5uudk:乐观更新 + config 变化补渲染)=====
|
|
4403
|
+
// 用户实证「池配置弹层勾选点击几秒才同步」。两段根因:
|
|
4404
|
+
// ① 受控 checkbox(checked 读 props)无乐观更新 → 点击要等 set-board-config + fetchTasks 双往返才翻面;
|
|
4405
|
+
// ② fetchTasks 在 tasksHash 不变时跳过 notify(渲染节约设计),config 字段照常赋值但无 notify →
|
|
4406
|
+
// 开关要等下一次任意 notify(安静板卡数秒)才翻面。
|
|
4407
|
+
// 断言口径:抽真函数原文执行(不猜注释)+ 源码级接线。键名/字段名从源码动态解析——新增开关自动纳入断言。
|
|
4408
|
+
test('池配置开关乐观更新:每个开关先写 state+notify 再 rpc,失败回滚并复用错误条(源码级实执)', async () => {
|
|
4409
|
+
const src = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
|
|
4410
|
+
function sliceFn(name, next) {
|
|
4411
|
+
const start = src.indexOf('function ' + name + '(')
|
|
4412
|
+
assert.ok(start >= 0, 'lib/client/dashboard.js 应定义 ' + name)
|
|
4413
|
+
const end = src.indexOf('function ' + next + '(', start + 1)
|
|
4414
|
+
assert.ok(end > start, name + ' 之后应紧跟 ' + next)
|
|
4415
|
+
return src.slice(start, end)
|
|
4416
|
+
}
|
|
4417
|
+
const pop = sliceFn('PoolCfgPopover', 'TeamView')
|
|
4418
|
+
// ① 每个开关行的 onChange 只能走 setCfg(乐观路径),不得再直接发 rpc(漏一个就回到「等往返才翻面」)
|
|
4419
|
+
const handlers = [...pop.matchAll(/type: 'checkbox', checked: [^,]+, onChange: function \(e\) \{ ([^}]*) \}/g)].map(m => m[1])
|
|
4420
|
+
assert.ok(handlers.length >= 3, '池配置应有 ≥3 个开关行(实测 ' + handlers.length + ' 个)')
|
|
4421
|
+
const keys = []
|
|
4422
|
+
for (const h of handlers) {
|
|
4423
|
+
const m = h.match(/^setCfg\('([a-zA-Z]+)', e\.target\.checked\)$/)
|
|
4424
|
+
assert.ok(m, '开关 onChange 必须是 setCfg(键, e.target.checked) 单调用,实测:' + h)
|
|
4425
|
+
keys.push(m[1])
|
|
4426
|
+
}
|
|
4427
|
+
assert.deepEqual(keys.slice().sort(), ['epicSplit', 'feedbackEnabled', 'notifyDispatch', 'notifyDone'])
|
|
4428
|
+
// ② 乐观序:先写 state、再 notify、最后才 rpc(顺序颠倒 = 点击仍等往返)
|
|
4429
|
+
const setCfgMatch = src.match(/function setCfg\(key, next\) \{[\s\S]*?\n \}/)
|
|
4430
|
+
assert.ok(setCfgMatch, '应定义 setCfg')
|
|
4431
|
+
const body = setCfgMatch[0]
|
|
4432
|
+
const iState = body.indexOf('state[key] = next === true')
|
|
4433
|
+
const iNotify = body.indexOf('notify()')
|
|
4434
|
+
const iRpc = body.indexOf("rpc('set-board-config'")
|
|
4435
|
+
assert.ok(iState >= 0 && iNotify > iState && iRpc > iNotify, '乐观序必须是 写 state → notify → rpc')
|
|
4436
|
+
// ③ 失败回滚分支在位:回滚 state + notify 提示,且回滚先于报错(绝不让开关停在未落盘的值上)
|
|
4437
|
+
const iCatch = body.indexOf('.catch(function (e) {')
|
|
4438
|
+
assert.ok(iCatch > iRpc, 'setCfg 必须有 .catch 失败分支')
|
|
4439
|
+
const catchBody = body.slice(iCatch)
|
|
4440
|
+
assert.match(catchBody, /cfgRollback\(key, prev\)/)
|
|
4441
|
+
assert.match(catchBody, /reportReadErr\('设置保存失败:' \+ readErrText\(e\)\)/)
|
|
4442
|
+
assert.ok(catchBody.indexOf('cfgRollback(key, prev)') < catchBody.indexOf('reportReadErr('), '先回滚再报错')
|
|
4443
|
+
const rollbackMatch = pop.match(/function cfgRollback\(key, prev\) \{ state\[key\] = prev; notify\(\) \}/)
|
|
4444
|
+
assert.ok(rollbackMatch, '回滚函数应同时还原 state 并 notify')
|
|
4445
|
+
// ④ 真执行:乐观翻面 + 成功不回滚 / 失败回滚 + 报错(抽真函数原文跑,不是断言注释)
|
|
4446
|
+
function mkHarness(fail) {
|
|
4447
|
+
const h = { state: { epicSplit: true, feedbackEnabled: false }, notified: 0, calls: [], errs: [] }
|
|
4448
|
+
h.notify = () => { h.notified++ }
|
|
4449
|
+
h.reportReadErr = (m) => { h.errs.push(m) }
|
|
4450
|
+
h.rpc = (method, args) => { h.calls.push([method, args]); return fail ? Promise.reject(new Error('boom')) : Promise.resolve({ ok: true }) }
|
|
4451
|
+
h.readErrText = (e) => String((e && e.message) || e || '网络异常')
|
|
4452
|
+
h.setCfg = new Function('state', 'notify', 'rpc', 'reportReadErr', 'readErrText',
|
|
4453
|
+
rollbackMatch[0] + '\n' + body + '\nreturn setCfg')(h.state, h.notify, h.rpc, h.reportReadErr, h.readErrText)
|
|
4454
|
+
return h
|
|
4455
|
+
}
|
|
4456
|
+
const ok = mkHarness(false)
|
|
4457
|
+
ok.setCfg('epicSplit', false)
|
|
4458
|
+
assert.equal(ok.state.epicSplit, false) // 点击瞬时翻面(rpc 尚未 resolve)
|
|
4459
|
+
assert.equal(ok.notified, 1)
|
|
4460
|
+
assert.deepEqual(ok.calls[0], ['set-board-config', { key: 'epicSplit', value: false }])
|
|
4461
|
+
await Promise.resolve().then(() => {}) // 放行成功分支微任务
|
|
4462
|
+
assert.equal(ok.state.epicSplit, false) // 成功路径不回滚
|
|
4463
|
+
const bad = mkHarness(true)
|
|
4464
|
+
bad.setCfg('feedbackEnabled', true)
|
|
4465
|
+
assert.equal(bad.state.feedbackEnabled, true) // 乐观置真
|
|
4466
|
+
assert.equal(bad.notified, 1)
|
|
4467
|
+
await Promise.resolve().then(() => {}).then(() => {})
|
|
4468
|
+
assert.equal(bad.state.feedbackEnabled, false) // 失败回滚到旧值
|
|
4469
|
+
assert.equal(bad.notified, 2) // 回滚立即补 notify(开关不留假态)
|
|
4470
|
+
assert.equal(bad.errs.length, 1)
|
|
4471
|
+
assert.match(bad.errs[0], /^设置保存失败:boom$/)
|
|
4472
|
+
})
|
|
4473
|
+
|
|
4474
|
+
test('fetchTasks 设置开关权威纠偏:hash 不变时配置变化补 notify + hash 短路结构保持(源码级)', () => {
|
|
4475
|
+
const src = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
|
|
4476
|
+
// ① 取值口径单点定义(缺字段/脏值=开,只有显式 false 才关),且四个开关字段确由它赋值
|
|
4477
|
+
const knobSrc = src.match(/function cfgKnobOf\(src, key\) \{[^\n]*\n/)[0]
|
|
4478
|
+
const changedSrc = src.match(/function cfgKnobsChanged\(a, b\) \{[^\n]*\n/)[0]
|
|
4479
|
+
const cfgKnobOf = new Function(knobSrc + '\nreturn cfgKnobOf')()
|
|
4480
|
+
const cfgKnobsChanged = new Function(changedSrc + '\nreturn cfgKnobsChanged')()
|
|
4481
|
+
assert.equal(cfgKnobOf({}, 'notifyDispatch'), true) // 老 host 无字段 → 开
|
|
4482
|
+
assert.equal(cfgKnobOf({ notifyDispatch: false }, 'notifyDispatch'), false) // 只有显式 false 才关
|
|
4483
|
+
const KNOBS = ['feedbackEnabled', 'notifyDispatch', 'notifyDone', 'epicSplit']
|
|
4484
|
+
for (const k of KNOBS) assert.match(src, new RegExp("state\\." + k + " = cfgKnobOf\\(d, '" + k + "'\\)"))
|
|
4485
|
+
// ② 真执行比较器:四个开关任一翻转都算变化(漏一个 → 该开关又要等下一次任意 notify)
|
|
4486
|
+
const allOn = { feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true }
|
|
4487
|
+
assert.equal(cfgKnobsChanged(allOn, Object.assign({}, allOn)), false)
|
|
4488
|
+
for (const k of KNOBS) {
|
|
4489
|
+
assert.equal(cfgKnobsChanged(allOn, Object.assign({}, allOn, { [k]: false })), true, k + ' 翻转必须被检测到')
|
|
4490
|
+
}
|
|
4491
|
+
// ③ 接线:赋值前快照(含全部开关)→ 赋值后比对 → 仅在 hash 短路(!tasksChanged)时补 notify
|
|
4492
|
+
const snap = src.match(/var cfgKnobs = \{[^}]*\}/)
|
|
4493
|
+
assert.ok(snap, 'fetchTasks 应在赋值前快照开关字段')
|
|
4494
|
+
const iSnap = src.indexOf(snap[0])
|
|
4495
|
+
for (const k of KNOBS) {
|
|
4496
|
+
assert.ok(snap[0].includes(k + ': state.' + k), '快照应含 ' + k)
|
|
4497
|
+
assert.ok(src.indexOf("state." + k + " = cfgKnobOf(d, '" + k + "')") > iSnap, k + ' 赋值必须在快照之后')
|
|
4498
|
+
}
|
|
4499
|
+
assert.match(src, /var cfgDelta = !tasksChanged && cfgKnobsChanged\(cfgKnobs, state\)/)
|
|
4500
|
+
// task-muwq9u04:usageSummary 变化与开关变化共用这一次补 notify(令牌区选范围即渲染)
|
|
4501
|
+
assert.match(src, /var usageDelta = !tasksChanged && JSON\.stringify\(state\.usageSummary \|\| null\) !== usageJsonPrev/)
|
|
4502
|
+
assert.match(src, /if \(cfgDelta \|\| usageDelta\) notify\(\)/)
|
|
4503
|
+
const iDelta = src.indexOf('var cfgDelta =')
|
|
4504
|
+
const iNotify = src.indexOf('if (cfgDelta || usageDelta) notify()')
|
|
4505
|
+
const iEsc = src.indexOf('if (tasksChanged) {\n var newEsc')
|
|
4506
|
+
assert.ok(iDelta < iNotify && iEsc > iNotify, '补 notify 必须在配置赋值之后、escalation 分支之前')
|
|
4507
|
+
// ④ 既有渲染节约(tasksHash 短路)逐字保持——本次只加不删
|
|
4508
|
+
assert.match(src, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
|
|
4509
|
+
assert.match(src, /if \(tasksChanged\) \{\s*\n\s*state\.tasksHash = newHash\s*\n\s*state\.tasks = \(d && d\.tasks\) \|\| \[\]\s*\n\s*\}/)
|
|
4510
|
+
// ⑤ 产物接线:client.js 为拼装产物,真源改动必须已重建(产物与源一致由 build-client --check 兜底)
|
|
4511
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
4512
|
+
assert.match(cli, /function cfgKnobOf\(src, key\)/)
|
|
4513
|
+
assert.match(cli, /if \(cfgDelta \|\| usageDelta\) notify\(\)/)
|
|
4514
|
+
assert.match(cli, /function setCfg\(key, next\)/)
|
|
4515
|
+
})
|
|
4516
|
+
|
|
4517
|
+
// ===== 池冻结根修(task-muwlepg6):幽灵占位 → capW=0 → 整池停止派发 =====
|
|
4518
|
+
// 活体事故形状(2026-10-06 实测看板):首个 continuable Worker 经 board_report 完成后,活跃表项(rec)
|
|
4519
|
+
// 因当时还没有上报通道收尾而漏摘;此后每张卡漏一条,5 条残留 + 1 个真在跑 → poolCycle 的
|
|
4520
|
+
// activeW=6 > maxWorkers=3 → capW 恒 0 → 9 张 pending 卡一张都派不出去、verifying 卡的 Verifier 也
|
|
4521
|
+
// 永不 spawn(pickDispatch 的 busyTaskIds 认出自己的幽灵 rec)——而 force dispatch-task 手动通道
|
|
4522
|
+
// (不经 capW / busyTaskIds)一切正常。下面三条用例把该形状与本卡的三层防御钉死。
|
|
4523
|
+
test('池冻结根修①(根因复现):活跃表残留被回收 → capW 不再被拖到 0,pending 卡当轮照常派发', async () => {
|
|
4524
|
+
// 幽灵三态(覆盖活体里出现的三种卡面):verifying(已交验收)/ resolved / archived,全都不可能是
|
|
4525
|
+
// 「Worker 正在跑」的形态,各自都带一条同构的 continuable worker rec。
|
|
4526
|
+
const gVerify = mkTask({ id: 'g-verify', title: '卡已交验收', status: 'verifying', claimedBy: 'ghost-1', claimedAt: '2026-10-06T10:29:28Z', runs: [{ role: 'worker', id: 'ghost-1', outcome: 'running', continuable: true, at: '2026-10-06T10:29:31Z' }] })
|
|
4527
|
+
const gResolved = mkTask({ id: 'g-resolved', title: '卡已 resolved', status: 'resolved', claimedBy: 'ghost-2' })
|
|
4528
|
+
const gArchived = mkTask({ id: 'g-archived', title: '卡已归档', status: 'archived', claimedBy: 'ghost-3' })
|
|
4529
|
+
// 真在跑的活卡(in-progress + claimedBy === rec.id):绝不能被回收——误杀等于整轮交付物丢失
|
|
4530
|
+
const live = mkTask({ id: 'live1', title: '真在跑', status: 'in-progress', claimedBy: 'live-child' })
|
|
4531
|
+
const p1 = mkTask({ id: 'p1', title: '待派 1', createdAt: '2026-01-01T00:00:01Z' })
|
|
4532
|
+
const p2 = mkTask({ id: 'p2', title: '待派 2', createdAt: '2026-01-01T00:00:02Z' })
|
|
4533
|
+
const p3 = mkTask({ id: 'p3', title: '待派 3', createdAt: '2026-01-01T00:00:03Z' })
|
|
4534
|
+
const board = mkBoard([gVerify, gResolved, gArchived, live, p1, p2, p3])
|
|
4535
|
+
const h = mkContinuableDispatch(board, { rootForSession: () => ({ id: FULL_SID }) })
|
|
4536
|
+
const now = Date.now()
|
|
4537
|
+
h.runs['g-verify'] = { id: 'ghost-1', childId: 'ghost-1', continuable: true, role: 'worker', taskId: 'g-verify', startedAt: now, settled: false }
|
|
4538
|
+
h.runs['g-resolved'] = { id: 'ghost-2', childId: 'ghost-2', continuable: true, role: 'worker', taskId: 'g-resolved', startedAt: now, settled: false }
|
|
4539
|
+
h.runs['g-archived'] = { id: 'ghost-3', childId: 'ghost-3', continuable: true, role: 'worker', taskId: 'g-archived', startedAt: now, settled: false }
|
|
4540
|
+
h.runs['live1'] = { id: 'live-child', childId: 'live-child', continuable: true, role: 'worker', taskId: 'live1', startedAt: now, settled: false }
|
|
4541
|
+
const logs = []
|
|
4542
|
+
const origLog = console.log
|
|
4543
|
+
console.log = function () { logs.push(Array.prototype.join.call(arguments, ' ')) }
|
|
4544
|
+
try { await h.dispatch.poolCycle(FULL_SID) } finally { console.log = origLog }
|
|
4545
|
+
// ① 幽灵条目全部摘除,活条目保留
|
|
4546
|
+
assert.equal(h.runs['g-resolved'], undefined)
|
|
4547
|
+
assert.equal(h.runs['g-archived'], undefined)
|
|
4548
|
+
assert.ok(h.runs['live1'], 'in-progress + claimedBy===rec.id 的真在跑 Worker 绝不能被回收')
|
|
4549
|
+
assert.ok(logs.some(s => /回收失效活跃表项(幽灵占位)3 个/.test(s)), '回收动作必须留痕(可观测,不静默)')
|
|
4550
|
+
// ② 幽灵摘除后,那张 verifying 卡**当轮**就拿到了 Verifier(旧形状:busyTaskIds 认出它自己的幽灵 rec
|
|
4551
|
+
// → 该卡永远进不了 Verifier 派发)。同一 taskId 的活跃表项从「worker 幽灵」换成「verifier 真 run」,
|
|
4552
|
+
// 正是「卡被解冻」的直接证据。
|
|
4553
|
+
assert.equal(h.runs['g-verify'].role, 'verifier')
|
|
4554
|
+
assert.deepEqual(h.log.oneShot.map(x => x.label), ['verifier:g-verify'])
|
|
4555
|
+
assert.equal(gVerify.verifierRun, String(h.runs['g-verify'].id))
|
|
4556
|
+
// ③ capW 恢复(maxWorkers=3 - 真活跃 1 = 2):pending 卡当轮派发——修复前 activeW=4 → capW=0 → 一张不派
|
|
4557
|
+
assert.deepEqual(h.log.continuable.map(x => x.label).sort(), ['worker:p1', 'worker:p2'])
|
|
4558
|
+
assert.equal(p1.status, 'in-progress'); assert.equal(p2.status, 'in-progress')
|
|
4559
|
+
assert.equal(p3.status, 'pending') // 并发上限内,留给下一轮(不是被幽灵卡住)
|
|
4560
|
+
// ④ 幽灵的 run 结局用与两条结算通道**同一个** settleRunRecord 补上(不再永久停在 running)
|
|
4561
|
+
assert.equal(gVerify.runs[0].outcome, 'completed')
|
|
4562
|
+
assert.ok(gVerify.runs[0].endedAt)
|
|
4563
|
+
assert.equal(gResolved.runs, undefined) // 无 runs[] 的卡照样摘表项,不因缺数据抛错
|
|
4564
|
+
// ⑤ 池状态快照不再出现幽灵(旧形状:poolStatus 里 4 条 busy,其中 3 条是幽灵)
|
|
4565
|
+
assert.deepEqual(board.poolStatus.workers.map(w => w.taskId), ['live1'])
|
|
4566
|
+
// ⑥ 下一轮心跳照常(池子活过来了):新 spawn 的 verifier rec 进快照、并发位如实计数——
|
|
4567
|
+
// activeW=3(live1+p1+p2)已到 maxWorkers 上限 → p3 仍按上限留在 pending(是上限,不是幽灵冻结)
|
|
4568
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4569
|
+
assert.deepEqual(board.poolStatus.workers.map(w => w.taskId).sort(), ['live1', 'p1', 'p2'])
|
|
4570
|
+
assert.deepEqual(board.poolStatus.verifiers.map(w => w.taskId), ['g-verify'])
|
|
4571
|
+
assert.equal(p3.status, 'pending')
|
|
4572
|
+
})
|
|
4573
|
+
|
|
4574
|
+
test('池冻结根修②(防御):派发单项异常不拖死同轮其余项,下一轮照常派发', async () => {
|
|
4575
|
+
// 注入点选「派发即回执」回调(回执/通知层是真实世界里最容易炸的一环):旧形状下它一抛就抛穿整个
|
|
4576
|
+
// toSpawn 循环——本轮后面的卡全部留在 'spawn-pending'(worker 位靠 isOrphan 2min 回收,
|
|
4577
|
+
// verifier/hook 位则没有任何自愈路径),且本轮派发整批作废。
|
|
4578
|
+
const a = mkTask({ id: 'a1', title: 'A', createdAt: '2026-01-01T00:00:01Z' })
|
|
4579
|
+
const b = mkTask({ id: 'b1', title: 'B', createdAt: '2026-01-01T00:00:02Z' })
|
|
4580
|
+
let boom = true, n = 0
|
|
4581
|
+
const spawned = []
|
|
4582
|
+
const h = mkContinuableDispatch(mkBoard([a, b]),
|
|
4583
|
+
{ notifyDispatched: function () { if (boom) { boom = false; throw new Error('receipt boom') } } },
|
|
4584
|
+
{
|
|
4585
|
+
subagents: {
|
|
4586
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4587
|
+
start: async (name, req) => { spawned.push(req.label); return { id: 'run-' + (++n), result: new Promise(function () {}), dispose: async function () {} } },
|
|
4588
|
+
startContinuable: async (spec) => { spawned.push(spec.label); return { childId: 'child-' + (++n), messageId: 'm' } },
|
|
4589
|
+
},
|
|
4590
|
+
})
|
|
4591
|
+
const errs = []
|
|
4592
|
+
const origErr = console.error
|
|
4593
|
+
console.error = function () { errs.push(Array.prototype.join.call(arguments, ' ')) }
|
|
4594
|
+
try { await h.dispatch.poolCycle(FULL_SID) } finally { console.error = origErr }
|
|
4595
|
+
// 异常被隔离并留痕(旧形状:异常抛穿 poolCycle,没有这一行)
|
|
4596
|
+
assert.ok(errs.some(s => /派发单项异常/.test(s)), '单项异常必须被捕获并留痕')
|
|
4597
|
+
// A 那次 spawn 其实成功了(异常发生在回执里)→ 认领位是真实 run id,两个 rec 都在活跃表
|
|
4598
|
+
assert.equal(a.claimedBy, 'child-1')
|
|
4599
|
+
assert.ok(h.runs['a1'] && h.runs['b1'])
|
|
4600
|
+
// 同轮其余项照常派发(旧形状:B 永远停在 'spawn-pending',只有 worker:a1 被 spawn)
|
|
4601
|
+
assert.deepEqual(spawned.sort(), ['worker:a1', 'worker:b1'])
|
|
4602
|
+
assert.equal(b.claimedBy, 'child-2')
|
|
4603
|
+
// 下一轮照常派发:池没进入冻结态(新增 pending 卡立刻被派出去)
|
|
4604
|
+
const c = mkTask({ id: 'c1', title: 'C', createdAt: '2026-01-01T00:00:03Z' })
|
|
4605
|
+
h.board.tasks.push(c)
|
|
4606
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4607
|
+
assert.equal(c.claimedBy, 'child-3')
|
|
4608
|
+
})
|
|
4609
|
+
|
|
4610
|
+
test('池冻结根修③(防御):spawn 抛异常时四种占位全清(worker/verifier/hook-pre/hook-post),下一轮照常派发', async () => {
|
|
4611
|
+
// 异常点在 spawnOneShot 的 try **之外**(pickProvider → subagents.list()):这是旧形状里最真实的
|
|
4612
|
+
// 抛穿路径——旧代码不接这一抛,第一项就把整个循环带走,紧随其后的 verifier 与两个 hook 占位
|
|
4613
|
+
// (spawn-pending / state='running')全部变成死占位。
|
|
4614
|
+
const w = mkTask({ id: 'w1', title: 'W', createdAt: '2026-01-01T00:00:01Z' })
|
|
4615
|
+
const v = mkTask({ id: 'v1', title: 'V', status: 'verifying', createdAt: '2026-01-01T00:00:02Z' })
|
|
4616
|
+
// claimedAt 取当下:本用例只验证「spawn 抛异常时的占位清理」,别让 isOrphan 的 2min 规则先把手动认领的
|
|
4617
|
+
// 史诗回收成 pending(那会把 hook 场景变成 worker 场景,测不到本该测的那条路径)
|
|
4618
|
+
const claimedAt = new Date().toISOString()
|
|
4619
|
+
const epicPre = mkTask({ id: 'e1', title: 'E-pre', status: 'in-progress', claimedBy: 'main', claimedAt: claimedAt, hooks: { pre: { enabled: true, prompt: '准备', state: 'idle', runId: null } } })
|
|
4620
|
+
const epicPost = mkTask({ id: 'e2', title: 'E-post', status: 'in-progress', claimedBy: 'main', claimedAt: claimedAt, hooks: { post: { enabled: true, prompt: '收口', state: 'running', pending: true } } })
|
|
4621
|
+
let boom = true, n = 0
|
|
4622
|
+
const spawned = []
|
|
4623
|
+
const h = mkContinuableDispatch(mkBoard([w, v, epicPre, epicPost]), {}, {
|
|
4624
|
+
subagents: {
|
|
4625
|
+
list: () => { if (boom) throw new Error('subagents list boom'); return ['mock'] },
|
|
4626
|
+
getProvider: () => ({ inheritsParentContext: false }),
|
|
4627
|
+
start: async (name, req) => { spawned.push(req.label); return { id: 'run-' + (++n), result: new Promise(function () {}), dispose: async function () {} } },
|
|
4628
|
+
startContinuable: async (spec) => { spawned.push(spec.label); return { childId: 'child-' + (++n), messageId: 'm' } },
|
|
4629
|
+
},
|
|
4630
|
+
})
|
|
4631
|
+
const errs = []
|
|
4632
|
+
const origErr = console.error
|
|
4633
|
+
console.error = function () { errs.push(Array.prototype.join.call(arguments, ' ')) }
|
|
4634
|
+
try { await h.dispatch.poolCycle(FULL_SID) } finally { console.error = origErr }
|
|
4635
|
+
assert.equal(errs.filter(s => /派发单项异常/.test(s)).length, 4, '四项派发各自被隔离并留痕')
|
|
4636
|
+
// 四种占位全部回收(旧形状:w 卡在 in-progress/spawn-pending,v 卡在 verifying/spawn-pending,
|
|
4637
|
+
// e1 卡在 hooks.pre.state='running'(串行闸门永久关闭=该史诗子任务永不派发),
|
|
4638
|
+
// e2 卡在 verifierRun='spawn-pending' 且待跑标记已清(=永久不收口))
|
|
4639
|
+
assert.equal(w.status, 'pending'); assert.equal(w.claimedBy, null); assert.equal(w.claimedAt, null)
|
|
4640
|
+
assert.equal(v.verifierRun, null); assert.equal(v.verifierRunAt, undefined)
|
|
4641
|
+
assert.equal(epicPre.hooks.pre.state, 'idle'); assert.equal(epicPre.hooks.pre.runId, null); assert.equal(epicPre.hooks.pre.pending, true)
|
|
4642
|
+
assert.equal(epicPost.verifierRun, null); assert.equal(epicPost.hooks.post.pending, true)
|
|
4643
|
+
assert.deepEqual(Object.keys(h.runs), []) // 没有半成品 rec 占并发位
|
|
4644
|
+
// 下一轮(spawn 恢复正常)照常派发四种角色——占位清干净的直接证据
|
|
4645
|
+
boom = false
|
|
4646
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4647
|
+
assert.deepEqual(spawned.sort(), ['hook-post:e2', 'hook-pre:e1', 'verifier:v1', 'worker:w1'])
|
|
4648
|
+
assert.equal(w.status, 'in-progress'); assert.match(String(w.claimedBy), /^child-/)
|
|
4649
|
+
assert.ok(v.verifierRun && v.verifierRun !== 'spawn-pending')
|
|
4650
|
+
assert.equal(epicPre.hooks.pre.state, 'running'); assert.ok(epicPre.hooks.pre.runId)
|
|
4651
|
+
assert.ok(epicPost.verifierRun && epicPost.verifierRun !== 'spawn-pending'); assert.equal(epicPost.hooks.post.pending, false)
|
|
4652
|
+
assert.equal(Object.keys(h.runs).length, 4)
|
|
4653
|
+
})
|
|
4654
|
+
|
|
4655
|
+
test('池冻结根修④(防御):settle 链里抛异常(kickCycle 注入)不拖死 tick,下一轮 poolCycle 照常派发', async () => {
|
|
4656
|
+
// 结算链在 host 事件回调里跑(onAgentStatus → settleContinuable → settleRun → settleWorker)。这里在
|
|
4657
|
+
// 结算尾部的 kickCycle 上注入异常(真实世界里它通往回执/通知层),验证:异常被结算链自己吃掉、
|
|
4658
|
+
// 任务状态照常推进、且**下一轮 poolCycle 照常派发**(tick 没有被拖死)。
|
|
4659
|
+
const w = mkTask({ id: 's1', title: '会结算的卡' })
|
|
4660
|
+
const p = mkTask({ id: 'p9', title: '后续待派', createdAt: '2026-01-01T00:00:09Z' })
|
|
4661
|
+
let boom = false, n = 0
|
|
4662
|
+
const board = Object.assign(mkBoard([w, p]), { maxWorkers: 1 }) // 上限 1:第一轮只派 s1,p9 留给下一轮
|
|
4663
|
+
const h = mkContinuableDispatch(board,
|
|
4664
|
+
{ kickCycle: function () { if (boom) throw new Error('kick boom') } },
|
|
4665
|
+
{
|
|
4666
|
+
subagents: {
|
|
4667
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4668
|
+
start: async (name, req) => ({ id: 'run-' + (++n), result: new Promise(function () {}), dispose: async function () {} }),
|
|
4669
|
+
startContinuable: async () => ({ childId: 'child-' + (++n), messageId: 'm' }),
|
|
4670
|
+
},
|
|
4671
|
+
})
|
|
4672
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4673
|
+
assert.equal(w.status, 'in-progress'); assert.equal(p.status, 'pending') // 上限内只派 w
|
|
4674
|
+
boom = true
|
|
4675
|
+
const errs = []
|
|
4676
|
+
const origErr = console.error
|
|
4677
|
+
console.error = function () { errs.push(Array.prototype.join.call(arguments, ' ')) }
|
|
4678
|
+
try {
|
|
4679
|
+
h.listeners[0].fn({ agent: { session: { id: 'child-1' } }, status: 'running' })
|
|
4680
|
+
h.listeners[0].fn({ agent: { session: { id: 'child-1' } }, status: 'idle' })
|
|
4681
|
+
for (let i = 0; i < 4; i++) await flush()
|
|
4682
|
+
} finally { console.error = origErr }
|
|
4683
|
+
assert.ok(errs.some(s => /settle worker failed/.test(s)), '结算链异常必须被吃掉并留痕')
|
|
4684
|
+
assert.equal(w.status, 'pending') // 状态推进照常(空文本 → 失败重排)
|
|
4685
|
+
assert.equal(w.retryCount, 1)
|
|
4686
|
+
assert.equal(h.runs['s1'], undefined)
|
|
4687
|
+
// tick 照常:下一轮 poolCycle 立刻把 s1 重新派出去(poolCycle 本体没有被结算异常拖死)
|
|
4688
|
+
boom = false
|
|
4689
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4690
|
+
assert.equal(w.status, 'in-progress')
|
|
4691
|
+
assert.ok(h.runs['s1'])
|
|
4692
|
+
})
|
|
4693
|
+
|
|
4694
|
+
test('池冻结根修⑤(接线,源码级):幽灵回收在活跃度计数之前 + 轮异常隔离 + 去抖 latch 兜底复位', () => {
|
|
4695
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
4696
|
+
const sto = readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
|
|
4697
|
+
// ① 回收必须在 activeW 计数与 pickDispatch 之前(否则本轮依旧被幽灵拖住)
|
|
4698
|
+
const iReap = dsp.indexOf('reapedN = await reapGhostRecs(sid, snap)')
|
|
4699
|
+
const iCount = dsp.indexOf('if (rc0.role === \'worker\') activeW++')
|
|
4700
|
+
assert.ok(iReap > 0 && iCount > iReap, '幽灵回收必须先于活跃度计数')
|
|
4701
|
+
// ② 回收后重读快照(否则空闲快进的 wt(snap) 会把收尾结果覆盖回旧状态)
|
|
4702
|
+
assert.match(dsp, /if \(reapedN > 0\) \{ try \{ snap = await rt\(sid\) \} catch \(_\) \{\} \}/)
|
|
4703
|
+
// ③ 本轮主体包 try(整轮异常隔离),且刻意不设再入 latch
|
|
4704
|
+
assert.match(dsp, /try \{ return await poolCycleBody\(sid, info, runs, snap\) \}/)
|
|
4705
|
+
assert.match(dsp, /catch \(e\) \{ console\.error\('\[task-board\] poolCycle 本轮异常(已隔离,下一轮照常)/)
|
|
4706
|
+
assert.match(dsp, /刻意\*\*不设\*\*再入守卫/)
|
|
4707
|
+
// ④ 持锁段按阶段隔离(五段各自 try/catch,回调照样返回 d → 该写的池状态一定写下去)
|
|
4708
|
+
assert.match(dsp, /回收段异常(本轮跳过回收,派发照常)/)
|
|
4709
|
+
assert.match(dsp, /派发决策异常(本轮跳过派发,池状态照常写)/)
|
|
4710
|
+
assert.match(dsp, /hook 段异常(本轮跳过 hook spawn)/)
|
|
4711
|
+
assert.match(dsp, /touches 展示态计算异常(本轮跳过)/)
|
|
4712
|
+
assert.match(dsp, /池状态快照异常(本轮跳过)/)
|
|
4713
|
+
// ⑤ 派发单项隔离 + 统一占位回收出口(四角色)
|
|
4714
|
+
assert.match(dsp, /async function releaseSpawnPlaceholder\(sid, sp, note\) \{/)
|
|
4715
|
+
assert.match(dsp, /派发单项异常(已回收占位,继续本轮其余项)/)
|
|
4716
|
+
assert.match(dsp, /await releaseSpawnPlaceholder\(sid, sp, 'spawn 失败,回收重新排队'\)/)
|
|
4717
|
+
assert.match(dsp, /await releaseSpawnPlaceholder\(sid, sp, 'spawn 失败'\)/)
|
|
4718
|
+
// ⑥ spawn-pending 死占位回收扩面到 in-progress(hook-post 的幂等占用位)
|
|
4719
|
+
assert.match(dsp, /if \(t\.verifierRun === 'spawn-pending' && \(now - new Date\(t\.verifierRunAt \|\| 0\)\.getTime\(\)\) > 120000\)/)
|
|
4720
|
+
assert.match(dsp, /reclaim-hook-pre/)
|
|
4721
|
+
// ⑦ 去抖 latch:时间戳 + 兜底复位 + reject 也复位 + poolCycle 调用包 try/catch
|
|
4722
|
+
assert.match(sto, /var STALE_LATCH_MS = 10000/)
|
|
4723
|
+
assert.match(sto, /if \(latchedAt && Date\.now\(\) - latchedAt < STALE_LATCH_MS\) return/)
|
|
4724
|
+
assert.match(sto, /tm\.timeout\(50\)\.then\(go, go\)/)
|
|
4725
|
+
assert.match(sto, /kickCycle → poolCycle 调用失败/)
|
|
4726
|
+
})
|
|
4727
|
+
|
|
4728
|
+
// ============================================================================
|
|
4729
|
+
// continuable 消息通道二修(task-muwox2ii,发版门禁 e2e 两条红的根因)
|
|
4730
|
+
// Bug A:续跑指令把权威锚死在「上文历史契约」→ 仲裁后的新指示没人执行,任务收不了口(touches-freeze 场景 H 红)
|
|
4731
|
+
// Bug B:doIntervene 只认 rec.run.localAgent(一次性 run 句柄)→ continuable Worker 收不到干预(steer 套件 4/8 红)
|
|
4732
|
+
// 断言①②③④ 与主窗口调研笔记一一对应。
|
|
4733
|
+
// ============================================================================
|
|
4734
|
+
|
|
4735
|
+
// ===== Bug A ①(行为级):仲裁消息原文随续跑指令投递 + 末尾立优先级声明 =====
|
|
4736
|
+
test('续跑优先级①(task-muwox2ii):仲裁消息原文随续跑指令投递,末尾立「最新裁决优先于历史契约」声明', async () => {
|
|
4737
|
+
const parent = { id: FULL_SID }
|
|
4738
|
+
const sent = []
|
|
4739
|
+
const t = mkTask({
|
|
4740
|
+
id: 'pr1', title: '裁决续跑卡', status: 'pending',
|
|
4741
|
+
// 真实形状:escalation(Worker 自己上报的疑问)+ arbitration(主窗口裁决答案)都在卡上
|
|
4742
|
+
messages: [
|
|
4743
|
+
{ kind: 'escalation', text: '要不要继续做?', at: '2026-01-01T00:00:01Z', by: 'child-old' },
|
|
4744
|
+
{ kind: 'arbitration', text: '无需额外信息,直接按完成契约上报完成即可。', at: '2026-01-01T00:00:02Z', by: 'main', action: 'resume' },
|
|
4745
|
+
],
|
|
4746
|
+
runs: [{ role: 'worker', id: 'child-old', at: '2026-01-01T00:00:00Z', model: '', outcome: 'incomplete', hardMin: 120, continuable: true }],
|
|
4747
|
+
})
|
|
4748
|
+
const h = mkContinuableDispatch(mkBoard([t]), { rootForSession: () => parent }, {
|
|
4749
|
+
subagents: {
|
|
4750
|
+
list: () => ['mock'], getProvider: () => ({ inheritsParentContext: false }),
|
|
4751
|
+
start: async () => { throw new Error('续跑命中时不该走一次性路径') },
|
|
4752
|
+
startContinuable: async () => { throw new Error('续跑命中时不该 fresh spawn') },
|
|
4753
|
+
sendMessage: async (sender, targetId, content, options) => { sent.push({ sender: sender, targetId: targetId, content: content, options: options }); return 'msg-2' },
|
|
4754
|
+
},
|
|
4755
|
+
})
|
|
4756
|
+
await h.dispatch.poolCycle(FULL_SID)
|
|
4757
|
+
assert.equal(sent.length, 1)
|
|
4758
|
+
const txt = sent[0].content[0].text
|
|
4759
|
+
// ① 仲裁消息在续跑指令里**可见**(与 fresh spawn 的 buildWorkerPrompt 同源 core.buildMessages)
|
|
4760
|
+
assert.match(txt, /### \[arbitration\] \(2026-01-01T00:00:02Z by main\)/)
|
|
4761
|
+
assert.match(txt, /无需额外信息,直接按完成契约上报完成即可。/)
|
|
4762
|
+
// ② 末尾的优先级声明:最新裁决/干预优先于历史契约,并显式给出「不要做其他事 vs 上报完成」的反例
|
|
4763
|
+
assert.match(txt, /【优先级声明】/)
|
|
4764
|
+
assert.match(txt, /本次消息与上面 messages 里的最新裁决\/干预,优先于上文历史中的原始任务契约/)
|
|
4765
|
+
assert.match(txt, /两者冲突时以最新指示为准/)
|
|
4766
|
+
assert.match(txt, /不要做其他事/)
|
|
4767
|
+
// 声明在仲裁消息**之后**(最后一句才读到,不会被当成插入语)
|
|
4768
|
+
assert.ok(txt.lastIndexOf('【优先级声明】') > txt.lastIndexOf('### [arbitration]'), '优先级声明必须在消息段之后')
|
|
4769
|
+
// 旧文案逐字保留(老断言与老 Worker 的行为锚点不变)
|
|
4770
|
+
assert.match(txt, /【断点续跑】/)
|
|
4771
|
+
assert.match(txt, /第 2 次尝试/)
|
|
4772
|
+
assert.match(txt, /任务契约与验收标准见上文历史/)
|
|
4773
|
+
assert.match(txt, /吃不准就 board_report escalate/)
|
|
4774
|
+
})
|
|
4775
|
+
|
|
4776
|
+
// ===== Bug A ②(源码级):断言接线不被将来改动悄悄抹掉 =====
|
|
4777
|
+
test('续跑优先级②(源码级):续跑文案用 core.buildMessages 带消息段 + 优先级声明文案在位且顺序正确', () => {
|
|
4778
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
4779
|
+
assert.match(dsp, /try \{ msgs = core\.buildMessages\(t\) \} catch \(_\) \{\}/) // 与 fresh spawn 同源(截断口径一致)
|
|
4780
|
+
assert.match(dsp, /该任务的最新消息(主窗口裁决\/高优干预\/驳回理由等,请务必遵循):/)
|
|
4781
|
+
assert.match(dsp, /【优先级声明】本次消息与上面 messages 里的最新裁决\/干预,优先于上文历史中的原始任务契约;两者冲突时以最新指示为准/)
|
|
4782
|
+
const iMsgs = dsp.indexOf('该任务的最新消息(主窗口裁决')
|
|
4783
|
+
const iDecl = dsp.indexOf('【优先级声明】本次消息与上面')
|
|
4784
|
+
assert.ok(iMsgs > 0 && iDecl > iMsgs, '先拼消息段,再拼优先级声明')
|
|
4785
|
+
})
|
|
4786
|
+
|
|
4787
|
+
// ===== Bug B ①④(行为级):continuable rec 干预送得到 + e2e 断言口径(channel='steer')不变 =====
|
|
4788
|
+
test('高优干预①(continuable):rec 无 run 也走宿主投递——host-protocol 优先、保留插件 source、channel=steer', async () => {
|
|
4789
|
+
const parent = { id: FULL_SID }
|
|
4790
|
+
const hostCalls = []
|
|
4791
|
+
const sent = []
|
|
4792
|
+
const DELIVER = Symbol.for('dsh.subagent.deliverPrompt')
|
|
4793
|
+
const board = mkBoard([mkTask({ id: 'iv1', title: 'continuable 干预卡', status: 'in-progress' })])
|
|
4794
|
+
// continuable rec 的真实形态:无 run、有 childId(spawnOneShot 的 continuable 分支)
|
|
4795
|
+
const runs = { iv1: { id: 'child-live', childId: 'child-live', continuable: true, ran: true, run: null, role: 'worker', taskId: 'iv1', startedAt: Date.now(), model: '', settled: false } }
|
|
4796
|
+
const handlers = mkRpcHandlers(board, { runsFor: () => runs, rootForSession: () => parent }, {
|
|
4797
|
+
subagents: {
|
|
4798
|
+
[DELIVER]: async function (p, cid, content, source, signal, delivery) { hostCalls.push({ p: p, cid: cid, content: content, source: source, signal: signal, delivery: delivery }); return 'm1' },
|
|
4799
|
+
sendMessage: async function (sender, cid, content, options) { sent.push({ sender: sender, cid: cid, content: content, options: options }); return 'm2' },
|
|
4800
|
+
},
|
|
4801
|
+
})
|
|
4802
|
+
const r = await handlers['intervene-agent']({ taskId: 'iv1', message: '插一条高优指令:上报时带上标记 STEER-MARKER-ABC。' })
|
|
4803
|
+
assert.equal(r.ok, true)
|
|
4804
|
+
assert.equal(r.delivered, true, 'continuable rec 干预必须真的送达(旧实现整段被跳过 → delivered 恒 false)')
|
|
4805
|
+
assert.equal(r.channel, 'steer', 'e2e 断言口径不变:channel 必须仍是 steer')
|
|
4806
|
+
assert.equal(hostCalls.length, 1)
|
|
4807
|
+
assert.deepEqual(sent, [], 'host-protocol 可用时不再重复走 sendMessage(一次干预只投一次)')
|
|
4808
|
+
assert.equal(hostCalls[0].p, parent) // 授权者 = 活的直接父 Agent
|
|
4809
|
+
assert.equal(hostCalls[0].cid, 'child-live') // 目标 = 该 continuable 子会话
|
|
4810
|
+
assert.equal(hostCalls[0].delivery, 'steer') // 就近 step 边界消费(不是等整 turn 的 queue)
|
|
4811
|
+
assert.ok(hostCalls[0].signal, '投递必须带 AbortSignal(SubagentSendMessageOptions 同族形态)')
|
|
4812
|
+
assert.match(hostCalls[0].content[0].text, /STEER-MARKER-ABC/) // payload 含干预文本
|
|
4813
|
+
assert.equal(hostCalls[0].source.kind, 'plugin:dsh-agent-board') // source 保留(e2e 消费断言靠它)
|
|
4814
|
+
assert.equal(hostCalls[0].source.form, 'notice')
|
|
4815
|
+
// 干预原文照旧落卡(详情页可见 + 后续派发随 prompt 注入,通道补充不改这条既有语义)
|
|
4816
|
+
assert.equal(board.tasks[0].messages.filter((m) => m.kind === 'intervention').length, 1)
|
|
4817
|
+
assert.equal(board.tasks[0].messages.filter((m) => m.kind === 'intervention')[0].text, '插一条高优指令:上报时带上标记 STEER-MARKER-ABC。')
|
|
4818
|
+
// ===== 主窗口高优干预锐化(2026-10-06 活体实证)=====
|
|
4819
|
+
// 病根补记:旧实现下 **continuable Worker 正在跑**(rec.continuable=true、run=null)时,干预依然落进
|
|
4820
|
+
// 「(无活跃 run,随下次派发注入)」分支——「有无活跃 run」的判定本身对 continuable 误阴。此处把
|
|
4821
|
+
// 「live continuable rec 的干预不落入该分支」锁成红绿断言。
|
|
4822
|
+
const note1 = board.tasks[0].history[board.tasks[0].history.length - 1].note
|
|
4823
|
+
assert.ok(!/无活跃 run/.test(note1), 'live continuable rec 干预不得落「无活跃 run」分支(旧实现正是在这里误阴): ' + note1)
|
|
4824
|
+
assert.ok(!/未实时送达/.test(note1), 'delivered=true 时不得带「未实时送达」后缀: ' + note1)
|
|
4825
|
+
assert.match(note1, /^高优干预: 插一条高优指令:上报时带上标记 STEER-MARKER-ABC。$/)
|
|
4826
|
+
})
|
|
4827
|
+
|
|
4828
|
+
// ===== Bug B ②(行为级):一次性 rec 的既有两跳通道逐字不变 =====
|
|
4829
|
+
test('高优干预②(一次性 rec 逐字不变):agent.steer 优先、steer 抛错回退 followup、零宿主投递', async () => {
|
|
4830
|
+
const steered = [], followed = [], sent = []
|
|
4831
|
+
const board = mkBoard([mkTask({ id: 'iv2', title: '一次性干预卡', status: 'in-progress' })])
|
|
4832
|
+
const runs = { iv2: { id: 'run-1', run: { localAgent: { steer: function (m) { steered.push(m) }, followup: function (m) { followed.push(m) } } }, role: 'worker', taskId: 'iv2', settled: false } }
|
|
4833
|
+
const handlers = mkRpcHandlers(board, { runsFor: () => runs, rootForSession: () => ({ id: FULL_SID }) }, {
|
|
4834
|
+
subagents: { sendMessage: async function () { sent.push('不该走宿主投递'); return 'm' } },
|
|
4835
|
+
})
|
|
4836
|
+
let r = await handlers['intervene-agent']({ taskId: 'iv2', message: '口径重写:先做 B 再做 A。' })
|
|
4837
|
+
assert.equal(r.ok, true); assert.equal(r.delivered, true); assert.equal(r.channel, 'steer')
|
|
4838
|
+
assert.equal(steered.length, 1); assert.deepEqual(followed, [])
|
|
4839
|
+
assert.deepEqual(sent, []) // 一次性 rec 绝不进宿主投递分支
|
|
4840
|
+
assert.equal(steered[0].source.kind, 'plugin:dsh-agent-board')
|
|
4841
|
+
assert.equal(steered[0].source.form, 'notice')
|
|
4842
|
+
assert.match(steered[0].content[0].text, /口径重写:先做 B 再做 A。/)
|
|
4843
|
+
// steer 抛错 → 回退 followup(第三跳不变;老宿主没有 steer 时同样落这里)
|
|
4844
|
+
runs.iv2 = { id: 'run-2', run: { localAgent: { steer: function () { throw new Error('no steer') }, followup: function (m) { followed.push(m) } } }, role: 'worker', taskId: 'iv2' }
|
|
4845
|
+
r = await handlers['intervene-agent']({ taskId: 'iv2', message: '再插一条' })
|
|
4846
|
+
assert.equal(r.delivered, true); assert.equal(r.channel, 'followup'); assert.equal(followed.length, 1)
|
|
4847
|
+
// 无活跃 rec:只记录,history 说明文案逐字不变
|
|
4848
|
+
const idle = mkTask({ id: 'iv8', title: '无活跃 run 卡', status: 'in-progress' })
|
|
4849
|
+
board.tasks.push(idle)
|
|
4850
|
+
r = await handlers['intervene-agent']({ taskId: 'iv8', message: '无 run 的干预' })
|
|
4851
|
+
assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
4852
|
+
assert.match(idle.history[idle.history.length - 1].note, /^高优干预: 无 run 的干预(无活跃 run,随下次派发注入)$/)
|
|
4853
|
+
})
|
|
4854
|
+
|
|
4855
|
+
// ===== Bug B ③(行为级):continuable 回退链三态 =====
|
|
4856
|
+
test('高优干预③(continuable 回退链):host 符号不可用 → sendMessage;两条都失败 → 记录注入 + history 留「未实时送达」', async () => {
|
|
4857
|
+
const sent = []
|
|
4858
|
+
const board = mkBoard([
|
|
4859
|
+
mkTask({ id: 'iv4', title: '回退 sendMessage 卡', status: 'in-progress' }),
|
|
4860
|
+
mkTask({ id: 'iv5', title: '死会话卡', status: 'in-progress' }),
|
|
4861
|
+
mkTask({ id: 'iv6', title: '缺 childId 卡', status: 'in-progress' }),
|
|
4862
|
+
])
|
|
4863
|
+
const runs = {
|
|
4864
|
+
iv4: { id: 'child-a', childId: 'child-a', continuable: true, run: null, role: 'worker', taskId: 'iv4', settled: false },
|
|
4865
|
+
iv5: { id: 'child-b', childId: 'child-b', continuable: true, run: null, role: 'worker', taskId: 'iv5', settled: false },
|
|
4866
|
+
iv6: { id: 'rec-no-child', continuable: true, run: null, role: 'worker', taskId: 'iv6', settled: false },
|
|
4867
|
+
}
|
|
4868
|
+
// 老宿主面:ctx.subagents 上没有 host-protocol 符号键方法,只有公开 sendMessage;
|
|
4869
|
+
// child-b 的会话已死(sendMessage 抛 NOT_RESUMABLE)
|
|
4870
|
+
const handlers = mkRpcHandlers(board, { runsFor: () => runs, rootForSession: () => ({ id: FULL_SID }) }, {
|
|
4871
|
+
subagents: {
|
|
4872
|
+
sendMessage: async function (sender, targetId, content, options) {
|
|
4873
|
+
if (targetId === 'child-b') throw new Error('subagent/not-resumable')
|
|
4874
|
+
sent.push({ sender: sender, targetId: targetId, content: content, options: options })
|
|
4875
|
+
return 'm'
|
|
4876
|
+
},
|
|
4877
|
+
},
|
|
4878
|
+
})
|
|
4879
|
+
// ① host 不可用 → 公开 sendMessage(卡2 续跑同通道),选项形态 { signal }
|
|
4880
|
+
let r = await handlers['intervene-agent']({ taskId: 'iv4', message: 'STEER-回退-MARKER' })
|
|
4881
|
+
assert.equal(r.ok, true); assert.equal(r.delivered, true); assert.equal(r.channel, 'steer')
|
|
4882
|
+
assert.equal(sent.length, 1); assert.equal(sent[0].targetId, 'child-a')
|
|
4883
|
+
assert.match(sent[0].content[0].text, /STEER-回退-MARKER/)
|
|
4884
|
+
assert.ok(sent[0].options && sent[0].options.signal, 'sendMessage 选项形态必须是 { signal }')
|
|
4885
|
+
// ② 会话已死 → 回退记录注入(现状行为),history 写明未实时送达
|
|
4886
|
+
r = await handlers['intervene-agent']({ taskId: 'iv5', message: '送给死会话的干预' })
|
|
4887
|
+
assert.equal(r.ok, true); assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
4888
|
+
const t5 = board.tasks.find((x) => x.id === 'iv5')
|
|
4889
|
+
assert.equal(t5.messages.filter((m) => m.kind === 'intervention').length, 1) // 干预原文照旧落卡(重派时会注入)
|
|
4890
|
+
assert.match(t5.history[t5.history.length - 1].note, /高优干预: 送给死会话的干预(干预未能实时送达(会话不可用),已转为重派注入)$/)
|
|
4891
|
+
// ③ 无 childId(老/异常 rec 形态)→ 同样回退记录注入,不留「已送达」假象
|
|
4892
|
+
r = await handlers['intervene-agent']({ taskId: 'iv6', message: '缺 childId 的干预' })
|
|
4893
|
+
assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
4894
|
+
const t6 = board.tasks.find((x) => x.id === 'iv6')
|
|
4895
|
+
assert.match(t6.history[t6.history.length - 1].note, /干预未能实时送达(会话不可用),已转为重派注入/)
|
|
4896
|
+
// ④ 已立 settled 旗(结算通道正在认领/会话收尾)→ **不投**(主窗口 2026-10-06 锐化的判活口径):
|
|
4897
|
+
// 投递会打在正在关门的会话上或静默丢失,故直接落记录注入;host/sendMessage 一次都不许调。
|
|
4898
|
+
runs.iv7 = { id: 'child-settled', childId: 'child-settled', continuable: true, run: null, role: 'worker', taskId: 'iv7', settled: true }
|
|
4899
|
+
board.tasks.push(mkTask({ id: 'iv7', title: '已结算 rec 卡', status: 'in-progress' }))
|
|
4900
|
+
const sentBefore = sent.length
|
|
4901
|
+
r = await handlers['intervene-agent']({ taskId: 'iv7', message: '送给已结算 rec 的干预' })
|
|
4902
|
+
assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
4903
|
+
assert.equal(sent.length, sentBefore, 'settled rec 不得再投递(sendMessage 零调用)')
|
|
4904
|
+
const t7 = board.tasks.find((x) => x.id === 'iv7')
|
|
4905
|
+
assert.match(t7.history[t7.history.length - 1].note, /干预未能实时送达(会话不可用),已转为重派注入/)
|
|
4906
|
+
assert.equal(t7.messages.filter((m) => m.kind === 'intervention').length, 1)
|
|
4907
|
+
// 四条路径的干预原文都进 messages(通道选择不改变「记录一条干预」的既有语义)
|
|
4908
|
+
assert.equal(t6.messages.filter((m) => m.kind === 'intervention').length, 1)
|
|
4909
|
+
})
|
|
4910
|
+
|
|
4911
|
+
// ===== Bug B ④(源码级):投递点接线不被将来改动悄悄抹掉 =====
|
|
4912
|
+
test('高优干预④(源码级):continuable 投递点 = host-protocol 符号键 + sendMessage 回退,且分路在 rec.continuable 上', () => {
|
|
4913
|
+
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
4914
|
+
assert.match(rpcSrc, /Symbol\.for\('dsh\.subagent\.deliverPrompt'\)/) // 宿主适配器入口
|
|
4915
|
+
assert.match(rpcSrc, /if \(rec && rec\.continuable === true\) \{/) // 分路门禁
|
|
4916
|
+
assert.match(rpcSrc, /await host\.call\(subagents, parent, childId, m\.content, m\.source, makeSignal\(\), 'steer'\)/)
|
|
4917
|
+
assert.match(rpcSrc, /await subagents\.sendMessage\(parent, childId, m\.content, \{ signal: makeSignal\(\) \}\)/)
|
|
4918
|
+
assert.match(rpcSrc, /if \(rec && rec\.run && rec\.run\.localAgent\) \{/) // 一次性路径原样保留
|
|
4919
|
+
assert.match(rpcSrc, /干预未能实时送达(会话不可用),已转为重派注入/)
|
|
4920
|
+
assert.match(rpcSrc, /if \(!childId \|\| rec\.settled === true \|\| !subagents \|\| !parent\) return \{ delivered: false \}/) // 判活口径(settled 旗)
|
|
4921
|
+
// 回退链顺序:host 投递在 sendMessage 之前(能保留插件 source 的通道优先)
|
|
4922
|
+
assert.ok(rpcSrc.indexOf('await host.call(subagents, parent, childId') < rpcSrc.indexOf('await subagents.sendMessage(parent, childId'), 'host 投递必须先于 sendMessage 回退')
|
|
4923
|
+
})
|