dsh-agent-board 1.7.0 → 1.7.1
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 +5 -2
- package/index.mjs +5 -0
- package/lib/client/board-list.js +11 -1
- package/lib/client/dashboard.js +47 -2
- package/lib/client/kernel.js +20 -7
- package/lib/client/task-detail.js +25 -0
- package/lib/client.js +103 -10
- package/lib/core.mjs +94 -1
- package/lib/dispatch.mjs +11 -1
- package/lib/rpc.mjs +27 -7
- package/lib/session.mjs +18 -2
- package/package.json +1 -1
- package/test/core.test.mjs +368 -5
package/test/core.test.mjs
CHANGED
|
@@ -13,6 +13,9 @@ import * as core from '../lib/core.mjs'
|
|
|
13
13
|
import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE } from '../index.mjs'
|
|
14
14
|
import { aggregateUsageSummary, readRunUsage, findRunLog } from '../index.mjs'
|
|
15
15
|
import { createRpc } from '../lib/rpc.mjs'
|
|
16
|
+
// no-root 刷屏根治(task-muuf0o7a)专项:会话层幻影板防线 + 派发层 root 闸门直调断言
|
|
17
|
+
import { createSession, isFullSessionId } from '../lib/session.mjs'
|
|
18
|
+
import { createDispatch } from '../lib/dispatch.mjs'
|
|
16
19
|
// host 端源码拼接(Phase 2 模块化后:薄壳 index.mjs + lib/*.mjs 领域模块),供源码级接线断言
|
|
17
20
|
const HOST_SOURCES = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/notify.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
|
|
18
21
|
function hostSrc() { return HOST_SOURCES.map(function (f) { return readFileSync(new URL(f, import.meta.url), 'utf8') }).join('\n') }
|
|
@@ -706,12 +709,16 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
|
|
|
706
709
|
// ===== create-task 空 description 软警告(E 卡)=====
|
|
707
710
|
// 轻量 RPC 直调 harness:mock ctx(tools/effect/webServer)+ deps 最小面,直接调 handlers['create-task']
|
|
708
711
|
// extra 可覆盖默认 deps(如 epic 预检用例注入 pushSysNote 捕获 / sessionCwd 解析根)
|
|
712
|
+
// 幻影板防线(task-muuf0o7a)落地后,create-task 对不完整短 id 直接报错——mock 默认 sid
|
|
713
|
+
// 必须是完整形态(session-xxxx-xxxx-...),否则全量 create-task 用例会被防线拦住。
|
|
714
|
+
const FULL_SID = 'session-test-0000-0000-000000000000'
|
|
709
715
|
function mkRpcHandlers(board, extra) {
|
|
710
716
|
const state = { handlers: {}, teamModeCache: {}, feedbackCache: {} }
|
|
711
|
-
const
|
|
717
|
+
const tools = {} // 工具通道捕获:双通道接线测试经 __tools['task_create'].execute(...) 直调
|
|
718
|
+
const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
|
|
712
719
|
const deps = Object.assign({
|
|
713
720
|
getActorId: () => 'tester', resolveRoot: (x) => x,
|
|
714
|
-
toolSessionId: () =>
|
|
721
|
+
toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
|
|
715
722
|
rootForSession: () => null, deriveWorkMode: () => 'solo', runsFor: () => [],
|
|
716
723
|
rt: async () => board, mutateLocked: (sid, fn) => fn(board),
|
|
717
724
|
maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
@@ -719,6 +726,7 @@ function mkRpcHandlers(board, extra) {
|
|
|
719
726
|
pushSysNote: () => {}, sessionCwd: () => '',
|
|
720
727
|
}, extra || {})
|
|
721
728
|
createRpc(ctx, state, deps)
|
|
729
|
+
state.handlers.__tools = tools
|
|
722
730
|
return state.handlers
|
|
723
731
|
}
|
|
724
732
|
const EMPTY_DESC_WARNING = '任务描述为空——Worker 只能凭标题猜需求,建议补一句目标/约束'
|
|
@@ -857,6 +865,119 @@ test('update-task RPC: 发布 epic(有子任务缺调研注入)→ pushSysNo
|
|
|
857
865
|
assert.equal(r3.ok, false); assert.match(r3.error, /not a draft/); assert.equal(notes2.length, 0)
|
|
858
866
|
})
|
|
859
867
|
|
|
868
|
+
// ===== 调研遵循·host 三件套(task-mutnj3a4):touches→suggestedContextFiles 桥接 + Verifier 归因 + 驳回 hint =====
|
|
869
|
+
|
|
870
|
+
test('suggestContextFiles: glob 跳过 / 锚点剥离 / 存在性过滤 / 去重 / 上限 20 全矩阵', () => {
|
|
871
|
+
// 非数组 / 空条目 / 非字符串 → 空
|
|
872
|
+
assert.deepEqual(core.suggestContextFiles(null, () => true), [])
|
|
873
|
+
assert.deepEqual(core.suggestContextFiles(['', ' ', 42], () => true), [])
|
|
874
|
+
// glob 条目(* ? [ ] { } 任一)跳过;具体文件保留
|
|
875
|
+
assert.deepEqual(core.suggestContextFiles(['src/**', '*.mjs', 'a?.js', 'x/[ab].js', 'x/{a,b}.js', 'lib/a.mjs'], () => true), ['lib/a.mjs'])
|
|
876
|
+
// 锚点 :L 段剥掉后再查存在性,返回剥锚后的相对原样
|
|
877
|
+
const seen = []
|
|
878
|
+
const r = core.suggestContextFiles(['lib/a.mjs:L10-L20'], (p) => { seen.push(p); return true })
|
|
879
|
+
assert.deepEqual(r, ['lib/a.mjs']); assert.deepEqual(seen, ['lib/a.mjs'])
|
|
880
|
+
// 存在性过滤:不存在的剔除
|
|
881
|
+
assert.deepEqual(core.suggestContextFiles(['a.mjs', 'b.mjs'], (p) => p === 'b.mjs'), ['b.mjs'])
|
|
882
|
+
// exists 抛异常视为不存在(宁缺勿滥)
|
|
883
|
+
assert.deepEqual(core.suggestContextFiles(['a.mjs'], () => { throw new Error('x') }), [])
|
|
884
|
+
// exists 缺省 → 退化为只做 glob/锚点过滤(与 epicPrecheck 同口径)
|
|
885
|
+
assert.deepEqual(core.suggestContextFiles(['a.mjs', 'src/**']), ['a.mjs'])
|
|
886
|
+
// 去重(按剥锚后原串)+ 顺序保持
|
|
887
|
+
assert.deepEqual(core.suggestContextFiles(['a.mjs', 'a.mjs:L5', 'b.mjs', 'a.mjs'], () => true), ['a.mjs', 'b.mjs'])
|
|
888
|
+
// 上限 20(与 contextFiles 上限一致)
|
|
889
|
+
const many = []
|
|
890
|
+
for (let i = 0; i < 25; i++) many.push('f' + i + '.mjs')
|
|
891
|
+
assert.equal(core.suggestContextFiles(many, () => true).length, 20)
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
test('attachContextSuggestions: 触发②且有建议 → warning 补尾 + 挂字段;否则形态不变', () => {
|
|
895
|
+
// 触发:warnings 含无调研警告且 touches 有可建议文件
|
|
896
|
+
const out = { ok: true }
|
|
897
|
+
const warns = core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'] }))
|
|
898
|
+
const suggested = core.attachContextSuggestions(out, warns, ['lib/a.mjs'], () => true)
|
|
899
|
+
assert.deepEqual(suggested, ['lib/a.mjs'])
|
|
900
|
+
assert.deepEqual(out.suggestedContextFiles, ['lib/a.mjs'])
|
|
901
|
+
assert.match(warns[0], /可直接 task_update contextFiles 补上/)
|
|
902
|
+
// 不触发①:无「未附调研上下文」警告(已有调研上下文)→ null,out 不挂字段
|
|
903
|
+
const out2 = { ok: true }
|
|
904
|
+
const warns2 = core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'], context: { files: [], notes: 'n' } }))
|
|
905
|
+
assert.equal(core.attachContextSuggestions(out2, warns2, ['lib/a.mjs'], () => true), null)
|
|
906
|
+
assert.equal('suggestedContextFiles' in out2, false)
|
|
907
|
+
// 不触发②:touches 全 glob → null,warning 原文不变(不补尾巴)
|
|
908
|
+
const out3 = { ok: true }
|
|
909
|
+
const warns3 = core.createTaskWarnings(mkTask({ description: 'd', touches: ['src/**'] }))
|
|
910
|
+
const before = warns3.join(';')
|
|
911
|
+
assert.equal(core.attachContextSuggestions(out3, warns3, ['src/**'], () => true), null)
|
|
912
|
+
assert.equal('suggestedContextFiles' in out3, false)
|
|
913
|
+
assert.equal(warns3.join(';'), before)
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
test('create-task 双通道:touches 指向真实文件且无调研上下文 → 挂 suggestedContextFiles + warning 补尾', async () => {
|
|
917
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'board-suggest-'))
|
|
918
|
+
fs.writeFileSync(path.join(tmp, 'real.mjs'), '// x')
|
|
919
|
+
const extra = { sessionCwd: () => tmp }
|
|
920
|
+
// RPC 通道:ghost.mjs 不存在、src/** 是 glob → 均剔除,只剩 real.mjs
|
|
921
|
+
const board = mkBoard([])
|
|
922
|
+
const r = await mkRpcHandlers(board, extra)['create-task']({ title: 'T', description: 'd', touches: ['real.mjs', 'ghost.mjs', 'src/**'] })
|
|
923
|
+
assert.equal(r.ok, true)
|
|
924
|
+
assert.deepEqual(r.suggestedContextFiles, ['real.mjs'])
|
|
925
|
+
assert.match(r.warning, /未附调研上下文/); assert.match(r.warning, /可直接 task_update contextFiles 补上/)
|
|
926
|
+
// 工具通道(同口径)
|
|
927
|
+
const board2 = mkBoard([])
|
|
928
|
+
const tools = mkRpcHandlers(board2, extra).__tools
|
|
929
|
+
const r2 = await tools['task_create'].execute({ title: 'T', description: 'd', touches: ['real.mjs'] }, {})
|
|
930
|
+
assert.equal(r2.ok, true)
|
|
931
|
+
assert.deepEqual(r2.suggestedContextFiles, ['real.mjs'])
|
|
932
|
+
assert.match(r2.warning, /可直接 task_update contextFiles 补上/)
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
test('create-task:touches 全 glob 或已有调研上下文 → 不挂 suggestedContextFiles(返回体形态不变)', async () => {
|
|
936
|
+
const board = mkBoard([])
|
|
937
|
+
const r = await mkRpcHandlers(board)['create-task']({ title: 'T', description: 'd', touches: ['src/**'] })
|
|
938
|
+
assert.equal(r.ok, true); assert.match(r.warning, /未附调研上下文/)
|
|
939
|
+
assert.equal('suggestedContextFiles' in r, false)
|
|
940
|
+
assert.ok(!/可直接 task_update/.test(r.warning)) // 无建议时尾巴也不补
|
|
941
|
+
const board2 = mkBoard([])
|
|
942
|
+
const r2 = await mkRpcHandlers(board2)['create-task']({ title: 'T', description: 'd', touches: ['lib/a.mjs'], contextNotes: '已调研' })
|
|
943
|
+
assert.equal(r2.ok, true); assert.equal('warning' in r2, false); assert.equal('suggestedContextFiles' in r2, false)
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
test('verify 驳回 hint:verify-task RPC + task_verify 工具 rejected 附重派提示,approved 不附', async () => {
|
|
947
|
+
// RPC 通道 rejected → 附 hint,驳回语义不变(回 in-progress)
|
|
948
|
+
const board = mkBoard([mkTask({ id: 'v1', status: 'verifying' })])
|
|
949
|
+
const r = await mkRpcHandlers(board)['verify-task']({ taskId: 'v1', verdict: 'rejected', comment: '跑偏了' })
|
|
950
|
+
assert.equal(r.ok, true); assert.match(r.hint, /驳回原因写进 description/); assert.match(r.hint, /新 Worker 没有上一轮记忆/)
|
|
951
|
+
assert.equal(board.tasks[0].status, 'in-progress')
|
|
952
|
+
// RPC 通道 approved → 无 hint 字段
|
|
953
|
+
const board2 = mkBoard([mkTask({ id: 'v2', status: 'verifying' })])
|
|
954
|
+
const r2 = await mkRpcHandlers(board2)['verify-task']({ taskId: 'v2', verdict: 'approved' })
|
|
955
|
+
assert.equal(r2.ok, true); assert.equal('hint' in r2, false)
|
|
956
|
+
// 工具通道 rejected → 同口径附 hint
|
|
957
|
+
const board3 = mkBoard([mkTask({ id: 'v3', status: 'verifying' })])
|
|
958
|
+
const r3 = await mkRpcHandlers(board3).__tools['task_verify'].execute({ taskId: 'v3', verdict: 'rejected', comment: '缺测试' }, {})
|
|
959
|
+
assert.equal(r3.ok, true); assert.match(r3.hint, /驳回原因写进 description/)
|
|
960
|
+
// 工具通道 approved → 无 hint
|
|
961
|
+
const board4 = mkBoard([mkTask({ id: 'v4', status: 'verifying' })])
|
|
962
|
+
const r4 = await mkRpcHandlers(board4).__tools['task_verify'].execute({ taskId: 'v4', verdict: 'approved' }, {})
|
|
963
|
+
assert.equal(r4.ok, true); assert.equal('hint' in r4, false)
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
test('buildVerifierPrompt: 含「立单缺调研」驳回归因条款', () => {
|
|
967
|
+
const p = core.buildVerifierPrompt(mkTask({ id: 'tx', status: 'verifying' }), '')
|
|
968
|
+
assert.match(p, /立单缺调研/)
|
|
969
|
+
assert.match(p, /驳回热点统计/)
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
test('调研遵循接线:双通道 attachContextSuggestions / 驳回 hint 双挂 / Verifier 归因条款(源码级断言)', () => {
|
|
973
|
+
const src = hostSrc()
|
|
974
|
+
assert.equal((src.match(/attachContextSuggestions\(/g) || []).length, 2) // task_create 工具 + create-task RPC
|
|
975
|
+
assert.equal((src.match(/\.hint = REJECT_REDISPATCH_HINT/g) || []).length, 2) // task_verify 工具 + verify-task RPC
|
|
976
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
977
|
+
assert.match(coreSrc, /立单缺调研/) // Verifier prompt 归因条款在位
|
|
978
|
+
assert.match(coreSrc, /export function suggestContextFiles/)
|
|
979
|
+
})
|
|
980
|
+
|
|
860
981
|
test('调研门禁接线:双通道 warning 调用 / epic 预检投递 / 读包失败落卡(源码级轻量断言)', () => {
|
|
861
982
|
const src = hostSrc()
|
|
862
983
|
assert.equal((src.match(/createTaskWarnings\(t\)/g) || []).length, 2) // task_create 工具 + create-task RPC
|
|
@@ -999,7 +1120,7 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
|
|
|
999
1120
|
// 候选教训两处触发:Verifier 驳回(文本通道 + 工具通道 + GUI RPC)与主窗口仲裁结论
|
|
1000
1121
|
assert.match(host, /pushRejectLesson\(d, t, vsecs\.verifySummary \|\| trimmed, t\.verification\.at\)/)
|
|
1001
1122
|
assert.match(host, /if \(!approved\) pushRejectLesson\(d, t, \(args\.summary \|\| ''\)/)
|
|
1002
|
-
assert.match(host, /if \(args\.verdict === 'rejected'\) pushRejectLesson\(d, t, args\.comment\)/)
|
|
1123
|
+
assert.match(host, /if \(args\.verdict === 'rejected'\) \{ pushRejectLesson\(d, t, args\.comment\); r\.hint = REJECT_REDISPATCH_HINT \}/) // verify-task RPC:候选教训 + 驳回重派 hint(task-mutnj3a4)同分支挂载
|
|
1003
1124
|
assert.match(host, /pushArbitrationLesson\(d, t, escQ, answer \|\| '', arbAt\)/)
|
|
1004
1125
|
// 生成前一律过 feedbackEnabled 总开关(关掉 = 不生成、不推)
|
|
1005
1126
|
assert.equal((host.match(/if \(!cfg\(d\)\.feedbackEnabled\) return false/g) || []).length, 2)
|
|
@@ -1136,9 +1257,9 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
|
|
|
1136
1257
|
assert.match(host, /claimApply\(d, t, 'spawn-pending', 'dispatch'\); if \(parentKickOnDispatch\(d, t\)\)/)
|
|
1137
1258
|
// get-tasks 现算 childStats(零存储)
|
|
1138
1259
|
assert.match(host, /d\.childStats = aggregateChildStats\(d\.tasks\)/)
|
|
1139
|
-
// 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw
|
|
1260
|
+
// 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、tasksHash(task-mutrtwin)扩展
|
|
1140
1261
|
assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT \} = core/)
|
|
1141
|
-
assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote \} = core/)
|
|
1262
|
+
assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash \} = core/)
|
|
1142
1263
|
// 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;core.mjs 不在 hostSrc 清单,单独读)
|
|
1143
1264
|
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
1144
1265
|
assert.match(coreSrc, /if \(verdict === 'approved' && isb\(t\)\) \{ var p = checkParentAuto\(d, t\)/)
|
|
@@ -1177,3 +1298,245 @@ test('调研门禁 UI 接线:卡片「⚠️ 无调研」徽章 + 详情「调
|
|
|
1177
1298
|
assert.match(cli, /if \(r && r\.warning\)/) // 创建表单消费 create-task 响应的 warning 字段
|
|
1178
1299
|
assert.match(cli, /'⚠️ ' \+ warn/) // warning 原文黄色行展示(C.warn)
|
|
1179
1300
|
})
|
|
1301
|
+
|
|
1302
|
+
// ===== 反馈修复(task-mutrtwin):僵尸 epic 归档放行 + get-tasks tasksHash =====
|
|
1303
|
+
// ① archive-task 对「无活跃 run 的 in-progress」放行(parentKick 僵尸态出清,反馈 n-mutma3mmwceq);
|
|
1304
|
+
// ② get-tasks 附 tasksHash(core 纯函数 djb2),kernel hash 相同跳渲染(反馈 n-mut9rzs2mkhg)。
|
|
1305
|
+
|
|
1306
|
+
test('tasksHash: 同输入同 hash(纯函数稳定;JSON 重解析的等价形态 hash 相同)', () => {
|
|
1307
|
+
const a = [mkTask({ id: 'a', title: '甲' }), mkTask({ id: 'b', status: 'in-progress' })]
|
|
1308
|
+
assert.equal(core.tasksHash(a), core.tasksHash(a))
|
|
1309
|
+
// 跨轮询 rt() 重新 JSON.parse 的同内容对象 → hash 相同(短路生效的前提)
|
|
1310
|
+
assert.equal(core.tasksHash(a), core.tasksHash(JSON.parse(JSON.stringify(a))))
|
|
1311
|
+
assert.equal(core.tasksHash([]), core.tasksHash([]))
|
|
1312
|
+
assert.equal(core.tasksHash(undefined), core.tasksHash(null)) // 非数组一律按空板兜底
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
test('tasksHash: 关键字段变 → hash 变;非关键字段(history/messages/usage)变 → hash 不变', () => {
|
|
1316
|
+
const h0 = core.tasksHash([mkTask({ id: 'a' })])
|
|
1317
|
+
// 关键字段逐一变化(驱动列表/详情渲染的字段)
|
|
1318
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', status: 'in-progress' })]), h0)
|
|
1319
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', title: '改名' })]), h0)
|
|
1320
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', priority: 'high' })]), h0)
|
|
1321
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', lastError: 'boom' })]), h0)
|
|
1322
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', lastProgress: { text: '过半', at: 'x' } })]), h0)
|
|
1323
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', resolvedAt: '2026-01-02T00:00:00Z' })]), h0)
|
|
1324
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', frozen: true })]), h0)
|
|
1325
|
+
// escalation 必须入 hash:board_report escalate 只写 escalation/messages/history、
|
|
1326
|
+
// 不动其他任何字段——漏掉它「新歧义自动弹开面板」会被 hash 短路吞掉
|
|
1327
|
+
assert.notEqual(core.tasksHash([mkTask({ id: 'a', escalation: { question: '歧义', at: 'x', by: 'w' } })]), h0)
|
|
1328
|
+
// 非关键字段不动 hash(不驱动列表渲染,参与会让结算写账/进展消息引发空渲染抖动)
|
|
1329
|
+
const t1 = mkTask({ id: 'a' }); t1.history.push({ from: 'pending', to: 'in-progress', timestamp: 'x', actor: 'y', note: '' })
|
|
1330
|
+
assert.equal(core.tasksHash([t1]), h0)
|
|
1331
|
+
const t2 = mkTask({ id: 'a' }); t2.messages.push({ kind: 'progress', text: 'p', at: 'x', by: 'w' })
|
|
1332
|
+
assert.equal(core.tasksHash([t2]), h0)
|
|
1333
|
+
const t3 = mkTask({ id: 'a' }); t3.usage = { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10, runs: 1, models: {} }
|
|
1334
|
+
assert.equal(core.tasksHash([t3]), h0)
|
|
1335
|
+
})
|
|
1336
|
+
|
|
1337
|
+
test('tasksHash: 顺序有关(数组序变化 → hash 变;看板按数组序渲染,口径写清)', () => {
|
|
1338
|
+
const a = mkTask({ id: 'a' }), b = mkTask({ id: 'b' })
|
|
1339
|
+
assert.notEqual(core.tasksHash([a, b]), core.tasksHash([b, a]))
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
test('archive-task RPC: resolved/cancelled 放行(既有行为不破)', async () => {
|
|
1343
|
+
const board = mkBoard([mkTask({ id: 'r1', status: 'resolved' }), mkTask({ id: 'c1', status: 'cancelled' })])
|
|
1344
|
+
const h = mkRpcHandlers(board)
|
|
1345
|
+
assert.equal((await h['archive-task']({ taskId: 'r1' })).ok, true)
|
|
1346
|
+
assert.equal((await h['archive-task']({ taskId: 'c1' })).ok, true)
|
|
1347
|
+
assert.equal(board.tasks[0].status, 'archived')
|
|
1348
|
+
assert.equal(board.tasks[1].status, 'archived')
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
test('archive-task RPC: in-progress 无活跃 run 放行(parentKick 僵尸 epic 出清)', async () => {
|
|
1352
|
+
// 僵尸态还原:direct epic 被 parentKickOnDispatch 推进到 in-progress,子任务已归档、无 run、claimedBy=null
|
|
1353
|
+
const zombie = mkTask({ id: 'z1', status: 'in-progress', claimedBy: null, pipeline: 'direct' })
|
|
1354
|
+
const board = mkBoard([zombie])
|
|
1355
|
+
const h = mkRpcHandlers(board, { hasActiveRun: () => false })
|
|
1356
|
+
const r = await h['archive-task']({ taskId: 'z1' })
|
|
1357
|
+
assert.equal(r.ok, true); assert.equal(zombie.status, 'archived'); assert.ok(zombie.archivedAt)
|
|
1358
|
+
})
|
|
1359
|
+
|
|
1360
|
+
test('archive-task RPC: in-progress 有活跃 run 拒绝;已 settled run 放行;未注入 hasActiveRun 按活跃兜底', async () => {
|
|
1361
|
+
// 有活跃 run → 拒绝(不能归在跑的任务)
|
|
1362
|
+
let t = mkTask({ id: 'a1', status: 'in-progress' })
|
|
1363
|
+
let h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => true })
|
|
1364
|
+
let r = await h['archive-task']({ taskId: 'a1' })
|
|
1365
|
+
assert.equal(r.ok, false); assert.match(r.error, /cannot archive/); assert.equal(t.status, 'in-progress')
|
|
1366
|
+
// 有 run 记录但已 settled(finish→settleRun 过渡窗口,Worker 已结束)→ 放行
|
|
1367
|
+
// (hasActiveRun 的 !settled 口径由 index.mjs 实现,此处 mock 其结论)
|
|
1368
|
+
t = mkTask({ id: 'a2', status: 'in-progress' })
|
|
1369
|
+
h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
|
|
1370
|
+
r = await h['archive-task']({ taskId: 'a2' })
|
|
1371
|
+
assert.equal(r.ok, true); assert.equal(t.status, 'archived')
|
|
1372
|
+
// deps 未注入 hasActiveRun → 保守兜底视为有活跃 run → 拒绝(保持旧门禁行为,不误放在跑任务)
|
|
1373
|
+
t = mkTask({ id: 'a3', status: 'in-progress' })
|
|
1374
|
+
h = mkRpcHandlers(mkBoard([t]))
|
|
1375
|
+
r = await h['archive-task']({ taskId: 'a3' })
|
|
1376
|
+
assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
|
|
1377
|
+
// 其余状态(verifying 等)仍拒绝——放行面只扩到「无活跃 run 的 in-progress」
|
|
1378
|
+
t = mkTask({ id: 'a4', status: 'verifying' })
|
|
1379
|
+
h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
|
|
1380
|
+
r = await h['archive-task']({ taskId: 'a4' })
|
|
1381
|
+
assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
|
|
1382
|
+
})
|
|
1383
|
+
|
|
1384
|
+
test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放行/活跃拒绝)', async () => {
|
|
1385
|
+
let t = mkTask({ id: 'z2', status: 'in-progress', claimedBy: null })
|
|
1386
|
+
let h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
|
|
1387
|
+
let r = await h.__tools['task_archive'].execute({ taskId: 'z2' }, {})
|
|
1388
|
+
assert.equal(r.ok, true); assert.equal(t.status, 'archived')
|
|
1389
|
+
t = mkTask({ id: 'z3', status: 'in-progress' })
|
|
1390
|
+
h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => true })
|
|
1391
|
+
r = await h.__tools['task_archive'].execute({ taskId: 'z3' }, {})
|
|
1392
|
+
assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
|
|
1393
|
+
})
|
|
1394
|
+
|
|
1395
|
+
test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 tasksHash + kernel 短路分支(源码级)', () => {
|
|
1396
|
+
const host = hostSrc()
|
|
1397
|
+
// index.mjs:hasActiveRun 定义(runsFor 表有记录且 !settled 即活跃)并注入 createRpc deps
|
|
1398
|
+
assert.match(host, /function hasActiveRun\(sid, taskId\) \{ var rec = session\.runsFor\(sid\)\[taskId\]; return !!\(rec && !rec\.settled\) \}/)
|
|
1399
|
+
assert.match(host, /hasActiveRun: hasActiveRun,/)
|
|
1400
|
+
// rpc.mjs:get-tasks 响应附 tasksHash(现算不落盘);archiveErr 门禁 = 定义1 + RPC1 + 工具1
|
|
1401
|
+
assert.match(host, /d\.tasksHash = tasksHash\(d\.tasks\)/)
|
|
1402
|
+
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
1403
|
+
assert.equal((rpcSrc.match(/archiveErr\(sid, t\)/g) || []).length, 3)
|
|
1404
|
+
// kernel:hash 短路分支存在(hash 相同跳过 tasks 赋值 + notify;老 host 无 hash 恒视为变化)
|
|
1405
|
+
const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
|
|
1406
|
+
assert.match(ksrc, /var newHash = \(d && d\.tasksHash\) \|\| ''/)
|
|
1407
|
+
assert.match(ksrc, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
|
|
1408
|
+
assert.match(ksrc, /if \(tasksChanged\) \{[\s\S]*?notify\(\)/)
|
|
1409
|
+
})
|
|
1410
|
+
|
|
1411
|
+
// ===== 无障碍两件套(task-mutrubay,反馈 n-mut9rzpyc7p1):卡片键盘可达 + 详情页「流转到」按钮组(源码级断言)=====
|
|
1412
|
+
test('无障碍接线:卡片 role/tabIndex/aria-label/onKeyDown/焦点框 + 详情页流转按钮组(源码级)', () => {
|
|
1413
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
1414
|
+
// ① 卡片键盘可达:button 角色 + Tab 序 + 读屏标签(标题+状态)+ Enter/Space 与点击共用同一激活函数 + 主题色焦点框
|
|
1415
|
+
assert.match(cli, /role: 'button', tabIndex: 0, 'aria-label': t\.title \+ '(' \+ \(statusLabels\[t\.status\] \|\| t\.status\) \+ ')'/)
|
|
1416
|
+
assert.match(cli, /onKeyDown: function \(e\) \{ if \(e\.key === 'Enter' \|\| e\.key === ' '\) \{ e\.preventDefault\(\); activate\(\) \} \}/)
|
|
1417
|
+
assert.match(cli, /onClick: activate,/) // 点击与键盘同一条激活路径(多选=切换选中,否则开详情)
|
|
1418
|
+
assert.match(cli, /outline: isCardFocus\(t\.id\) \? '2px solid ' \+ C\.brand : 'none'/) // 主题色焦点框(非浏览器默认蓝框)
|
|
1419
|
+
assert.match(cli, /function isCardFocus\(id\)/) // 焦点态走 state 现算(轮询重渲染不丢焦点框)
|
|
1420
|
+
// ② 详情页「流转到」按钮组:合法迁移表 + 组容器(读屏可报组名)+ 四状态按钮文案
|
|
1421
|
+
assert.match(cli, /var FLOW_DEF = \{/)
|
|
1422
|
+
assert.match(cli, /role: 'group', 'aria-label': '状态流转'/)
|
|
1423
|
+
assert.match(cli, /'⇄ 流转到:'/)
|
|
1424
|
+
assert.match(cli, /'▶ 开始处理'/); assert.match(cli, /'✅ 提交验收'/); assert.match(cli, /'⛔ 标记阻塞'/)
|
|
1425
|
+
assert.match(cli, /'↩ 重投待办'/); assert.match(cli, /'✔ 验收通过'/); assert.match(cli, /'↩ 驳回重投'/)
|
|
1426
|
+
// 迁移通道与拖拽 transition() 同 RPC 族(host 门禁不变);失败原因 ⚠️ 回显 actionMsg 行
|
|
1427
|
+
assert.match(cli, /function flowTo\(to\)/)
|
|
1428
|
+
assert.match(cli, /p = rpc\('claim-task', \{ taskId: task\.id \}\)/)
|
|
1429
|
+
assert.match(cli, /p = rpc\('update-task', \{ taskId: task\.id, resetToPending: true \}\)/)
|
|
1430
|
+
assert.match(cli, /p = rpc\('resolve-task', \{ taskId: task\.id, status: 'verifying', resolution: res \}\)/)
|
|
1431
|
+
assert.match(cli, /p = rpc\('verify-task', \{ taskId: task\.id, verdict: 'approved' \}\)/)
|
|
1432
|
+
assert.match(cli, /setActionMsg\('⚠️ 流转失败:' \+ \(r\.error \|\| '未知错误'\)\)/)
|
|
1433
|
+
})
|
|
1434
|
+
|
|
1435
|
+
// ===== no-root 刷屏根治(task-muuf0o7a):poolCycle root 闸门 + 短 id 幻影板防线 =====
|
|
1436
|
+
// 事故链:幻影板 tasks-cc24eb5c.json(裸短 id 建出)滞留 pending 卡 → poolCycle 每 15s
|
|
1437
|
+
// pickDispatch 成功→占位 claim→spawnOneShot 才发现无活 root→console.error→占位超时回收
|
|
1438
|
+
// →下轮再来,永久 spam。根治 = ①poolCycle 入口 root 闸门(无 root 不派发不写盘零日志)
|
|
1439
|
+
// + ②touchSession/create-task 拒绝不完整短 id(import- 前缀放行)。
|
|
1440
|
+
|
|
1441
|
+
test('isFullSessionId:裸短 id/空值/unknown 拒绝;完整 root id 与 import- 前缀放行', () => {
|
|
1442
|
+
assert.equal(isFullSessionId('cc24eb5c'), false) // 裸短 id(本次幻影板元凶)
|
|
1443
|
+
assert.equal(isFullSessionId(''), false)
|
|
1444
|
+
assert.equal(isFullSessionId(null), false)
|
|
1445
|
+
assert.equal(isFullSessionId(undefined), false)
|
|
1446
|
+
assert.equal(isFullSessionId('unknown'), false)
|
|
1447
|
+
assert.equal(isFullSessionId('abcdefghij-k'), false) // 长度 <20
|
|
1448
|
+
assert.equal(isFullSessionId('abcdefghijklmno-pqrst'), false) // ≥20 但只有一个 '-'
|
|
1449
|
+
assert.equal(isFullSessionId('-abcdefghij-klmnopqr'), false) // 首字符即 '-' 不算结构
|
|
1450
|
+
assert.equal(isFullSessionId('session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6'), true)
|
|
1451
|
+
assert.equal(isFullSessionId('import-sess_df0837fa-1'), true) // import- 前缀板合法放行
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
test('touchSession:不完整短 id 不注册进已知会话集合(幻影板不进心跳轮询)', () => {
|
|
1455
|
+
const known = {}
|
|
1456
|
+
const session = createSession({}, { knownSessions: known, feedbackCache: {}, activeRuns: {}, dispatchedEver: {} })
|
|
1457
|
+
session.touchSession('cc24eb5c') // 裸短 id → 拒绝
|
|
1458
|
+
session.touchSession('unknown') // 既有排除项不变
|
|
1459
|
+
session.touchSession(null)
|
|
1460
|
+
session.touchSession('session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6') // 完整 id → 注册
|
|
1461
|
+
session.touchSession('import-sess_df0837fa-1') // import- 前缀 → 放行
|
|
1462
|
+
assert.deepEqual(Object.keys(known).sort(), ['import-sess_df0837fa-1', 'session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6'].sort())
|
|
1463
|
+
})
|
|
1464
|
+
|
|
1465
|
+
// poolCycle 直调 harness:ctx 不给 timer(15s 心跳 IIFE 跳过),deps 全 mock,计数读/写/日志
|
|
1466
|
+
function mkDispatch(board, over) {
|
|
1467
|
+
const counters = { reads: 0, writes: 0, errs: [] }
|
|
1468
|
+
const dispatch = createDispatch(
|
|
1469
|
+
// 创建期无条件触达:effect(卸载清理注册)/ get('systemPrompt' 引导段);不给 timer/agents/subagents
|
|
1470
|
+
{ fs: {}, effect: function () {}, get: function () { return null } },
|
|
1471
|
+
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} },
|
|
1472
|
+
Object.assign({
|
|
1473
|
+
rt: async () => { counters.reads++; return board },
|
|
1474
|
+
wt: async () => { counters.writes++ },
|
|
1475
|
+
mutateLocked: async (sid, fn) => { counters.writes++; return fn(board) },
|
|
1476
|
+
kickCycle: () => {},
|
|
1477
|
+
rootForSession: () => undefined, // 默认无活 root(幻影板/死会话场景)
|
|
1478
|
+
sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => ({}), feedbackOn: () => true,
|
|
1479
|
+
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
1480
|
+
}, over || {}))
|
|
1481
|
+
return { dispatch, counters }
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
test('poolCycle root 闸门:无活 root → 直接返回 undefined,不读盘/不写盘/零日志(根治 15s 刷屏)', async () => {
|
|
1485
|
+
const { dispatch, counters } = mkDispatch(mkBoard([mkTask()])) // 板上有 pending 卡也不进循环
|
|
1486
|
+
const origErr = console.error
|
|
1487
|
+
console.error = function () { counters.errs.push(Array.prototype.join.call(arguments, ' ')) }
|
|
1488
|
+
try {
|
|
1489
|
+
const r = await dispatch.poolCycle('cc24eb5c')
|
|
1490
|
+
assert.equal(r, undefined) // 调用方(kickCycle/15s 心跳)忽略返回值,签名兼容
|
|
1491
|
+
assert.equal(counters.reads, 0) // rt 都没跑:零读盘
|
|
1492
|
+
assert.equal(counters.writes, 0) // 零写盘
|
|
1493
|
+
assert.deepEqual(counters.errs, []) // 零日志
|
|
1494
|
+
} finally { console.error = origErr }
|
|
1495
|
+
})
|
|
1496
|
+
|
|
1497
|
+
test('poolCycle root 闸门:有活 root → 正常进循环(空板走空闲快进返回 snap,不写盘)', async () => {
|
|
1498
|
+
const board = mkBoard([])
|
|
1499
|
+
const { dispatch, counters } = mkDispatch(board, { rootForSession: () => ({ id: FULL_SID }) })
|
|
1500
|
+
const r = await dispatch.poolCycle(FULL_SID)
|
|
1501
|
+
assert.ok(r && Array.isArray(r.tasks)) // 空闲快进返回 snap(闸门未误伤正常路径)
|
|
1502
|
+
assert.equal(counters.reads, 1)
|
|
1503
|
+
assert.equal(counters.writes, 0) // 空板无残留 poolStatus/dispatchInfo → 快进不写盘
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
test('create-task RPC:不完整短 id sessionId → 报错不建板;import- 前缀与正常路径放行', async () => {
|
|
1507
|
+
// rpcSessionId 覆写为「尊重显式 sessionId 入参」(生产行为:args.sessionId 优先于 actor 归一)
|
|
1508
|
+
const honorSid = { rpcSessionId: (args) => (args && args.sessionId) || FULL_SID }
|
|
1509
|
+
// ① 裸短 id → 拒绝且不落任务(防幻影板)
|
|
1510
|
+
const board = mkBoard([])
|
|
1511
|
+
const r = await mkRpcHandlers(board, honorSid)['create-task']({ title: 'T', description: 'd', sessionId: 'cc24eb5c' })
|
|
1512
|
+
assert.equal(r.ok, false)
|
|
1513
|
+
assert.match(r.error, /sessionId 不完整/)
|
|
1514
|
+
assert.equal(board.tasks.length, 0)
|
|
1515
|
+
// ② import- 前缀板 → 放行(导入会话是合法板,别误伤)
|
|
1516
|
+
const board2 = mkBoard([])
|
|
1517
|
+
const r2 = await mkRpcHandlers(board2, honorSid)['create-task']({ title: 'T', description: 'd', sessionId: 'import-sess_df0837fa-1' })
|
|
1518
|
+
assert.equal(r2.ok, true)
|
|
1519
|
+
assert.equal(board2.tasks.length, 1)
|
|
1520
|
+
// ③ 不传 sessionId(正常路径,归一为完整 root id)→ 放行,返回体形态不变
|
|
1521
|
+
const board3 = mkBoard([])
|
|
1522
|
+
const r3 = await mkRpcHandlers(board3, honorSid)['create-task']({ title: 'T', description: 'd' })
|
|
1523
|
+
assert.equal(r3.ok, true)
|
|
1524
|
+
assert.equal(board3.tasks.length, 1)
|
|
1525
|
+
})
|
|
1526
|
+
|
|
1527
|
+
test('no-root 刷屏根治接线断言(源码级):闸门/防线/兜底注释均在位', () => {
|
|
1528
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
1529
|
+
const ses = readFileSync(new URL('../lib/session.mjs', import.meta.url), 'utf8')
|
|
1530
|
+
const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
1531
|
+
// ① poolCycle 入口即 root 闸门(闸门必须在 rt 读盘之前——不读盘才谈得上零 IO)
|
|
1532
|
+
assert.match(dsp, /async function poolCycle\(sid\) \{[\s\S]{0,900}if \(!rootForSession\(sid\)\) return undefined[\s\S]{0,200}var snap = await rt\(sid\)/)
|
|
1533
|
+
// spawnOneShot 的 console.error 兜底保留 + 注释说明闸门在上游
|
|
1534
|
+
assert.match(dsp, /console\.error\('\[task-board\] no root agent for session ' \+ sid \+ ', skip spawn'\)/)
|
|
1535
|
+
assert.match(dsp, /poolCycle 入口已有 root 存活早闸门/)
|
|
1536
|
+
// ② touchSession 注册口接入 isFullSessionId;纯函数出口 + rpc.mjs 直引
|
|
1537
|
+
assert.match(ses, /export function isFullSessionId\(sid\)/)
|
|
1538
|
+
assert.match(ses, /sid !== 'unknown' && isFullSessionId\(sid\)\) knownSessions\[sid\]/)
|
|
1539
|
+
assert.match(rpc, /import \{ isFullSessionId \} from '\.\/session\.mjs'/)
|
|
1540
|
+
// create-task handler 校验在 mutateLocked 之前(拒绝时不建板)
|
|
1541
|
+
assert.match(rpc, /handle\('create-task', async function \(args\) \{ var sid = rpcSessionId\(args\);[\s\S]{0,400}if \(!isFullSessionId\(sid\)\) return \{ ok: false, error: 'sessionId 不完整[\s\S]{0,120}return mutateLocked/)
|
|
1542
|
+
})
|