dsh-agent-board 1.7.9 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/index.mjs +1 -0
- package/lib/client/dashboard.js +11 -8
- package/lib/client.js +11 -8
- package/lib/core.mjs +15 -2
- package/lib/dispatch.mjs +122 -5
- package/lib/rpc.mjs +71 -9
- package/lib/usage.mjs +13 -4
- package/package.json +1 -1
- package/test/core.test.mjs +258 -1
- package/test/integration.test.mjs +153 -0
- package/test/scoreboard-client.test.mjs +15 -9
- package/test/scoreboard.test.mjs +44 -4
package/lib/rpc.mjs
CHANGED
|
@@ -47,6 +47,10 @@ export function createRpc(ctx, state, deps) {
|
|
|
47
47
|
function rpcActor(args) { var a = getActorId(); if (a && a !== 'unknown') return a; return rpcSessionId(args) }
|
|
48
48
|
var rootForSession = deps.rootForSession, deriveWorkMode = deps.deriveWorkMode, runsFor = deps.runsFor
|
|
49
49
|
var rt = deps.rt, mutateLocked = deps.mutateLocked
|
|
50
|
+
// 续跑资格判定(task-mv6c7cbo):doIntervene 无活跃 rec 时细分「续跑窗口 / 真无 run」。
|
|
51
|
+
// 与 dispatch 同一函数(createDispatch 返回体透出,index.mjs 接线注入),不另写第二套口径;
|
|
52
|
+
// 老宿主/测试桩未注入时退化空函数——claimedBy 窗口判定仍可命中,语义只降不炸。
|
|
53
|
+
var resumeTarget = typeof deps.resumeTarget === 'function' ? deps.resumeTarget : function () { return '' }
|
|
50
54
|
// 主窗口消耗(task-muwsol23):测试可注入临时日志根;生产 undefined → findRunLog 回退 ~/.dsh/sessions
|
|
51
55
|
var sessionsRoot = (typeof deps.sessionsRoot === 'string' && deps.sessionsRoot) ? deps.sessionsRoot : undefined
|
|
52
56
|
// ===== 已结算会话清理(task-mv2131cz):路径根 + 活跃/血统保护集 =====
|
|
@@ -286,7 +290,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
286
290
|
// 缺省/空范围 = 现状逐字不变(aggregateUsageSummary 内部判定,空串不算范围)。
|
|
287
291
|
d.usageSummary = aggregateUsageSummary(d.tasks, args && args.range)
|
|
288
292
|
// ===== 记分卡聚合底座(task-muxhtgh9 卡2):usageSummary.scoreboard =====
|
|
289
|
-
//
|
|
293
|
+
// 模型×角色×规模段八指标桶 + 全局质量趋势 rollup;与 usageSummary 同一 range 口径(run 日落点裁剪)。
|
|
290
294
|
// 增量缓存 state.scoreboardCache[sid]:键=(taskCount, 最新落定时刻),命中则提取层零重算,
|
|
291
295
|
// 范围裁剪在装配层现算(缓存的是未裁剪归一化记录,不随范围漂移)。
|
|
292
296
|
d.usageSummary.scoreboard = buildScoreboard(d.tasks, args && args.range, state.scoreboardCache[sid] || (state.scoreboardCache[sid] = {}))
|
|
@@ -640,8 +644,36 @@ export function createRpc(ctx, state, deps) {
|
|
|
640
644
|
try { agent.followup(m); delivered = true; channel = 'followup' } catch (_) {}
|
|
641
645
|
}
|
|
642
646
|
}
|
|
643
|
-
|
|
644
|
-
|
|
647
|
+
// ===== 无活跃 rec 的送达提示两态细分(task-mv6c7cbo,实证 n-mv65bhk0mg5w)=====
|
|
648
|
+
// intervene 时刻 run 可能刚结算(incomplete,rec 已摘除)而卡仍在续跑/重排窗口——「无活跃 run」
|
|
649
|
+
// 技术为真但对用户误导:干预落进 t.messages,续跑指令经 buildMessages 原文带上,注入语义依旧成立。
|
|
650
|
+
// 锁内新鲜快照判两态:① resumeTarget(t)!==''(下次重派走续跑)或 claimedBy 还在(结算/重排窗口)
|
|
651
|
+
// → 文案如实写「随续跑注入」,state='resume-pending';
|
|
652
|
+
// ② 真无 run(无续跑资格且无认领位)→ 维持原「随下次派发注入」文案,state='no-run'。
|
|
653
|
+
// state 全枚举:delivered(实时送达)/ redispatch(会话不可用转重派注入)/ resume-pending / no-run,
|
|
654
|
+
// 供 GUI 区分提示色。
|
|
655
|
+
var state = delivered ? 'delivered' : (missedNote ? 'redispatch' : '')
|
|
656
|
+
await mutateLocked(sid, function (d) {
|
|
657
|
+
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
658
|
+
if (t) {
|
|
659
|
+
if (!Array.isArray(t.messages)) t.messages = []
|
|
660
|
+
t.messages.push({ kind: 'intervention', text: msg, at: new Date().toISOString(), by: actor })
|
|
661
|
+
var note = delivered ? '' : missedNote
|
|
662
|
+
if (!delivered && !note) {
|
|
663
|
+
if (resumeTarget(t) !== '' || t.claimedBy) {
|
|
664
|
+
note = '(run 刚结算/续跑窗口,干预将随续跑注入)'
|
|
665
|
+
state = 'resume-pending'
|
|
666
|
+
} else {
|
|
667
|
+
note = '(无活跃 run,随下次派发注入)'
|
|
668
|
+
state = 'no-run'
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
ah(t, t.status, t.status, actor, '高优干预: ' + msg.slice(0, 200) + note)
|
|
672
|
+
}
|
|
673
|
+
return t
|
|
674
|
+
})
|
|
675
|
+
if (!state) state = 'no-run' // 兜底:卡已不存在等异常形态也给确定态(此时干预无处落卡)
|
|
676
|
+
return { ok: true, delivered: delivered, channel: channel, state: state }
|
|
645
677
|
}
|
|
646
678
|
// 终止执行某任务的 run:一次性 dispose / continuable interrupt 后回 pending(verifying 则保持待审,由新 verifier 接手)
|
|
647
679
|
async function doTerminate(sid, actor, taskId) {
|
|
@@ -725,6 +757,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
725
757
|
var t = d.tasks.find(function (x) { return x.id === args.taskId })
|
|
726
758
|
if (!t) return { ok: false, error: 'task disappeared' }
|
|
727
759
|
var rec = await spawnOneShot(sid, t, role)
|
|
760
|
+
// 落闸弃权(task-mv6c7cdm):spawn 在途被 terminate 等路径翻卡——闸门已停掉刚建的子会话,
|
|
761
|
+
// 占位由翻卡那笔写清掉。返回显式错误(误报 spawn failed 会误导成「再点一次」,实际是状态变了)
|
|
762
|
+
if (rec && rec.vetoed) return { ok: false, error: 'spawn-vetoed:派发途中任务状态已变更(可能刚被终止/重排),本轮已放弃,请查看卡片后重试' }
|
|
728
763
|
if (rec) {
|
|
729
764
|
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.claimedBy === 'spawn-pending') t2.claimedBy = String(rec.id); return t2 }, true) }
|
|
730
765
|
if (role === 'verifier') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2) t2.verifierRun = String(rec.id); return t2 }, true) }
|
|
@@ -738,7 +773,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
738
773
|
handle('intervene-agent', async function (args) { return doIntervene(rpcSessionId(args), rpcActor(args), args.taskId, args.message) })
|
|
739
774
|
// 主 Agent 工具版(Team 模式下主 Agent 通过工具裁决/介入)
|
|
740
775
|
ctx.tools.register(defineTool({ name: 'task_arbitrate', description: 'Team 模式:裁决 Worker 上报的歧义(escalation)。action=resume(默认)裁决后任务回派发池立即重派;action=hold 任务挂起冻结、不参与自动派发(留给主窗口补 dependsOn/上下文,补完后用 task_update unfreeze:true 解冻)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, answer: { type: 'string' }, action: { type: 'string', enum: ['resume', 'hold'], description: 'resume(默认)=裁决后重新派发;hold=挂起冻结不派发' } }, required: ['taskId', 'answer'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doResolveEscalation(toolSessionId(), getActorId(), args.taskId, args.answer, args.action) } }))
|
|
741
|
-
ctx.tools.register(defineTool({ name: 'task_intervene', description: 'Team 模式:向执行某任务的池中 Agent 发起高优先级指令(steer 通道,当前 step 结束即响应;无活跃 run
|
|
776
|
+
ctx.tools.register(defineTool({ name: 'task_intervene', description: 'Team 模式:向执行某任务的池中 Agent 发起高优先级指令(steer 通道,当前 step 结束即响应;无活跃 run 时记录随下次派发注入——run 刚结算/续跑窗口内随续跑注入,真无 run 随下次派发注入,返回体 state 字段区分:delivered / redispatch / resume-pending / no-run)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, message: { type: 'string' } }, required: ['taskId', 'message'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doIntervene(toolSessionId(), getActorId(), args.taskId, args.message) } }))
|
|
742
777
|
// ===== 池中 Agent 结构化回报工具(双模:工具优先,文本分段为降级路径)=====
|
|
743
778
|
ctx.tools.register(defineTool({ name: 'board_report', description: '[任务看板 Worker 专用] 上报任务结果。kind=complete 时填 summary/changes/selfTest/diffStat(git 仓库内改动附 git diff --stat 概要);kind=escalate 时填 question(歧义上报,等待主窗口裁决);kind=progress 时填 question(一行里程碑进展摘要,≤200 字符)——只在有实际产物/结论时报,禁止定时或表演式汇报,静默不通知主窗口。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, kind: { type: 'string', enum: ['complete', 'escalate', 'progress'] }, summary: { type: 'string' }, changes: { type: 'string' }, selfTest: { type: 'string' }, diffStat: { type: 'string', description: '变更概要:git diff --stat(含 git status --short)输出,≤1500 字符' }, question: { type: 'string', description: 'kind=escalate:歧义原文;kind=progress:一行里程碑进展摘要(≤200 字符)' } }, required: ['taskId', 'kind'] }, output: jo(), execute: async function (args) {
|
|
744
779
|
var sid = toolSessionId(); var actor = getActorId()
|
|
@@ -831,9 +866,10 @@ export function createRpc(ctx, state, deps) {
|
|
|
831
866
|
}
|
|
832
867
|
return result
|
|
833
868
|
} }))
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
869
|
+
// 枚举当前网关可用模型(list-models handler 与 set-board-config 模型校验共用):
|
|
870
|
+
// llm.listProviders + listModels;llm 服务缺失(老宿主)返回 null——调用方据此降级,不炸。
|
|
871
|
+
async function enumModels() {
|
|
872
|
+
var llm = ctx.get('llm'); if (!llm) return null
|
|
837
873
|
var providers = llm.listProviders()
|
|
838
874
|
var out = []
|
|
839
875
|
for (var i = 0; i < providers.length; i++) {
|
|
@@ -842,7 +878,13 @@ export function createRpc(ctx, state, deps) {
|
|
|
842
878
|
for (var j = 0; j < models.length; j++) out.push({ id: providers[i].id + '/' + models[j].id, name: models[j].name || models[j].id, provider: providers[i].name || providers[i].id })
|
|
843
879
|
} catch (_) { /* 某 provider 枚举失败不阻塞整体 */ }
|
|
844
880
|
}
|
|
845
|
-
return
|
|
881
|
+
return out
|
|
882
|
+
}
|
|
883
|
+
// 枚举当前网关可用模型(供 Verifier 模型下拉选择):llm.listProviders + listModels
|
|
884
|
+
handle('list-models', async function () {
|
|
885
|
+
var models = await enumModels()
|
|
886
|
+
if (!models) return { ok: false, error: 'llm service unavailable' }
|
|
887
|
+
return { ok: true, models: models }
|
|
846
888
|
})
|
|
847
889
|
// ===== 学习飞轮 v1:沉淀推送通道(push-lesson)=====
|
|
848
890
|
// 详情页「沉淀」按钮 → 把结构化候选教训 followup 给主窗口 agent,由它用自己可用的
|
|
@@ -870,7 +912,27 @@ export function createRpc(ctx, state, deps) {
|
|
|
870
912
|
// 史诗拆分总开关 epicSplit(UI「功能」小节):布尔原样落盘 + **当场回填 epicSplitCache**——Team 提示词
|
|
871
913
|
// 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
|
|
872
914
|
// 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
|
|
873
|
-
handle('set-board-config', async function (args) {
|
|
915
|
+
handle('set-board-config', async function (args) {
|
|
916
|
+
var sid = rpcSessionId(args)
|
|
917
|
+
// ===== 模型字段校验(task-mv73ym2k,实证 n-mv6ezj6vary4:手写显示名当 id → 验收 spawn UNKNOWN_MODEL 秒死×2 + 熔断误报「连续失败」)=====
|
|
918
|
+
// workerModel/verifierModel 的非空值必须先过合法 id 校验:经 enumModels() 枚举当前网关清单(list-models 同款口径),
|
|
919
|
+
// 不命中则拒写并返回合法清单样例(前 8 个防刷屏);空串=清除覆盖,放行。
|
|
920
|
+
// 降级纪律:llm 服务缺失(老宿主,enumModels 返回 null)跳过校验直接放行——配置面绝不因宿主能力缺失而炸。
|
|
921
|
+
var mkey = (args.key === 'workerModel' || args.key === 'verifierModel') ? args.key : null
|
|
922
|
+
var mval = mkey && typeof args.value === 'string' ? args.value.trim() : ''
|
|
923
|
+
if (mkey && mval) {
|
|
924
|
+
var legal = await enumModels()
|
|
925
|
+
if (legal) {
|
|
926
|
+
var hit = false
|
|
927
|
+
for (var mi = 0; mi < legal.length; mi++) { if (legal[mi].id === mval) { hit = true; break } }
|
|
928
|
+
if (!hit) {
|
|
929
|
+
var sample = legal.slice(0, 8).map(function (m) { return m.id })
|
|
930
|
+
return { ok: false, error: '非法模型 id: ' + mval + '(不在当前网关可用清单)。合法清单(' + legal.length + ' 个,示前 ' + sample.length + ' 个):' + (sample.join('、') || '(空)') + '。完整清单见 list-models' }
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'cleanupRetentionDays') d.cleanupRetentionDays = Math.max(1, Math.min(90, Number(args.value) || 7)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'workerContinuable') d.workerContinuable = !!args.value; else if (args.key === 'verifyUserGuide') d.verifyUserGuide = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } })
|
|
935
|
+
})
|
|
874
936
|
handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor = rpcActor(args); /* hooks 只许主窗口设置(角色门禁) */ if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; return mutateLocked(sid, function (d) { var nHooks = null; if (args.hooks !== undefined) { var hn = normalizeHooks(args.hooks); if (hn.error) return { ok: false, error: hn.error }; nHooks = hn.hooks } if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (nHooks && Object.keys(nHooks).length) t.hooks = nHooks; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ var out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var warns = createTaskWarnings(t); attachContextSuggestions(out, warns, t.touches, existsInSession(sid)); if (warns.length) out.warning = warns.join(';'); return out }) })
|
|
875
937
|
handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
|
|
876
938
|
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
|
package/lib/usage.mjs
CHANGED
|
@@ -632,7 +632,7 @@ export function attributeRejection(t, rejectedAt) {
|
|
|
632
632
|
}
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
-
// ===== 记分卡聚合底座(task-muxhtgh9 卡2):scoreboard =
|
|
635
|
+
// ===== 记分卡聚合底座(task-muxhtgh9 卡2):scoreboard = 模型×角色×规模段八指标桶 + 全局质量趋势 rollup =====
|
|
636
636
|
// 设计稿 v3(笔记 n-muwsq9yztrpa)。数据源与卡1相同:任务级 t.runs[] 留账 + t.messages 驳回包
|
|
637
637
|
// (kind='rejection',三条驳回来源统一落点,pushRejection 已按 at/文本头判重)+ 落定时刻
|
|
638
638
|
// (approved 验收 = t.verifiedAt;direct/work 档直落 resolved = t.resolvedAt)。纯函数零 IO:
|
|
@@ -641,7 +641,7 @@ export function attributeRejection(t, rejectedAt) {
|
|
|
641
641
|
// 两层结构(增量缓存的关键):
|
|
642
642
|
// 提取层 buildScoreRecs(tasks):扫全部任务的 runs/驳回消息,产出归一化记录 —— 唯一的重活
|
|
643
643
|
// (逐 run 归一 + 驳回归因 + 日落点解析 + 任务级规模段都在这层)。
|
|
644
|
-
// 装配层 assembleScoreboard(recs, range)
|
|
644
|
+
// 装配层 assembleScoreboard(recs, range):八指标桶 + 趋势 rollup + 范围裁剪,每次调用现算,
|
|
645
645
|
// 输入只是归一化记录(轻量数组 pass)——范围切换不触发重提取。
|
|
646
646
|
// 增量缓存:runs 只增不改 —— 缓存槽(调用方持有,与 mainWindow 尾读缓存并列在 state)形态
|
|
647
647
|
// { taskCount, stamp, recs },键 = (任务数, 最新落定时刻)。stamp 在「最新 run at/endedAt」上扩了
|
|
@@ -814,7 +814,7 @@ function assembleScoreboard(recs, range) {
|
|
|
814
814
|
var key = q.model + '|' + q.role + '|' + q.size
|
|
815
815
|
var b = bmap[key]
|
|
816
816
|
if (!b) {
|
|
817
|
-
b = { key: key, model: q.model, role: q.role, size: q.size, runs: 0, effSum: 0, effCount: 0, durs: [], rejected: 0, resolved: 0, firstPass: 0, timeouts: 0, resumes: 0, inpSum: 0, crSum: 0 }
|
|
817
|
+
b = { key: key, model: q.model, role: q.role, size: q.size, runs: 0, effSum: 0, effCount: 0, durs: [], rejected: 0, resolved: 0, firstPass: 0, timeouts: 0, failed: 0, resumes: 0, inpSum: 0, crSum: 0 }
|
|
818
818
|
bmap[key] = b; bTasks[key] = {}
|
|
819
819
|
}
|
|
820
820
|
b.runs++
|
|
@@ -822,6 +822,11 @@ function assembleScoreboard(recs, range) {
|
|
|
822
822
|
if (q.eff !== null) { b.effSum += q.eff; b.effCount++ }
|
|
823
823
|
if (q.durMs !== null) b.durs.push(q.durMs)
|
|
824
824
|
if (q.outcome === 'timeout/error' || q.outcome === 'timeout') b.timeouts++
|
|
825
|
+
// 失败计数(task-mv73ym6i):跑崩落定的 run 不再隐形——outcome ∈ {incomplete, error, failed}
|
|
826
|
+
// (settleRun 失败臂无 err 时落定 'incomplete',flash 验收员猝死实证就是这种);'timeout/error'
|
|
827
|
+
// 有独立超时率维度(上一行)不重复计;terminated = 人工终止(人工动作非模型表现)、
|
|
828
|
+
// stale-closed = 老卡 running 残留关账,均不算失败。
|
|
829
|
+
if (q.outcome === 'incomplete' || q.outcome === 'error' || q.outcome === 'failed') b.failed++
|
|
825
830
|
if (q.resume) b.resumes++
|
|
826
831
|
if (q.inp !== null || q.cr !== null) { b.inpSum += q.inp || 0; b.crSum += q.cr || 0 }
|
|
827
832
|
bTasks[key][q.taskId] = true
|
|
@@ -853,7 +858,7 @@ function assembleScoreboard(recs, range) {
|
|
|
853
858
|
if (!tk.rejectCount) bb.firstPass++
|
|
854
859
|
}
|
|
855
860
|
}
|
|
856
|
-
// =====
|
|
861
|
+
// ===== 八指标定稿(task-mv73ym6i 补失败率维度)=====
|
|
857
862
|
var out = []
|
|
858
863
|
for (var fk in bmap) {
|
|
859
864
|
if (!Object.prototype.hasOwnProperty.call(bmap, fk)) continue
|
|
@@ -878,6 +883,10 @@ function assembleScoreboard(recs, range) {
|
|
|
878
883
|
// 口径);若某 provider 把缓存读并进 input,分母被双计、命中率失真——跨 provider 对比先核各家口径。
|
|
879
884
|
// 无 usage 样本(inpSum+crSum=0)→ null(不假装 0)。
|
|
880
885
|
cacheHitRate: (f.inpSum + f.crSum) > 0 ? f.crSum / (f.inpSum + f.crSum) : null,
|
|
886
|
+
// ⑧ 失败率(task-mv73ym6i)= 跑崩落定 run / 桶内 runs:outcome ∈ {incomplete, error, failed}。
|
|
887
|
+
// terminated(人工终止)不计入——那是人工动作不是模型跑崩;stale-closed(老卡残留关账)同不计;
|
|
888
|
+
// 与 ⑤ 超时率互补不重叠(timeout/error 只走超时维度)。
|
|
889
|
+
failed: f.failed, failRate: f.runs ? f.failed / f.runs : 0,
|
|
881
890
|
// 样本量护栏:runs < 5 标 insufficient(客户端据此灰显不排名)
|
|
882
891
|
insufficient: f.runs < SCOREBOARD_MIN_SAMPLE
|
|
883
892
|
})
|
package/package.json
CHANGED
package/test/core.test.mjs
CHANGED
|
@@ -480,25 +480,54 @@ test('buildVerifierPrompt: 注入交付物 + 验收脚本 + messages', () => {
|
|
|
480
480
|
assert.match(p, /做完了/); assert.match(p, /npm test/); assert.match(p, /board_verdict/); assert.match(p, /方案B/)
|
|
481
481
|
})
|
|
482
482
|
|
|
483
|
-
test('buildWorkerPrompt: git
|
|
483
|
+
test('buildWorkerPrompt: git 纪律红线(禁还原命令 + 精确路径 add)注入,verifier prompt 不注入(源码断言)', () => {
|
|
484
484
|
const t = mkTask({ id: 'g1' })
|
|
485
485
|
const p = core.buildWorkerPrompt(t)
|
|
486
486
|
assert.match(p, /git 纪律红线/)
|
|
487
487
|
assert.match(p, /禁止 git checkout \/ git restore \/ git reset --hard \/ git clean 等还原命令/)
|
|
488
488
|
assert.match(p, /会冲掉并行 Worker 与你自己的未提交在途编辑/)
|
|
489
489
|
assert.match(p, /确实需要干净基线时用 board_report(kind=escalate)上报/)
|
|
490
|
+
// 精确路径 add 条款(task-mv73ylwe;n-mv6xntg1p6i5 实证:并行 Worker 共享暂存区,宽泛 add 吞并他卡在途编辑)
|
|
491
|
+
assert.match(p, /提交时只允许精确路径 git add <本卡 touches 声明的文件>/)
|
|
492
|
+
assert.match(p, /禁止 git add -A \/ git add \. \/ git add 目录/)
|
|
493
|
+
assert.match(p, /并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进你的 commit/)
|
|
490
494
|
// verifier prompt 不动
|
|
491
495
|
const pv = core.buildVerifierPrompt(t)
|
|
492
496
|
assert.doesNotMatch(pv, /git 纪律红线/)
|
|
493
497
|
assert.doesNotMatch(pv, /reset --hard/)
|
|
498
|
+
assert.doesNotMatch(pv, /精确路径 git add/)
|
|
499
|
+
assert.doesNotMatch(pv, /git add -A/)
|
|
494
500
|
// 源码级:条款只在 buildWorkerPrompt 体内(切片边界到 buildVerifierPrompt 之前),不在 buildVerifierPrompt 体内
|
|
495
501
|
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
496
502
|
const wBody = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
|
|
497
503
|
assert.ok(wBody.length > 500, 'buildWorkerPrompt 切片成功')
|
|
498
504
|
assert.match(wBody, /git 纪律红线/)
|
|
499
505
|
assert.match(wBody, /git reset --hard/)
|
|
506
|
+
assert.match(wBody, /提交时只允许精确路径 git add/)
|
|
507
|
+
assert.match(wBody, /禁止 git add -A/)
|
|
500
508
|
const vBody = coreSrc.slice(coreSrc.indexOf('export function buildVerifierPrompt'), coreSrc.indexOf('export function buildHookPrompt'))
|
|
501
509
|
assert.doesNotMatch(vBody, /git 纪律红线/)
|
|
510
|
+
assert.doesNotMatch(vBody, /精确路径 git add/)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
test('buildWorkerPrompt: 大文件交付纪律(write 主体+edit 续块)注入,verifier prompt 不注入(源码断言)', () => {
|
|
514
|
+
const t = mkTask({ id: 'lf1' })
|
|
515
|
+
const p = core.buildWorkerPrompt(t)
|
|
516
|
+
assert.match(p, /大文件交付纪律/)
|
|
517
|
+
assert.match(p, /单文件交付预估 >8KB(或 spec 含全量文案\/长代码)时,禁止单次 write 写入全量/)
|
|
518
|
+
assert.match(p, /先 write 落主体骨架,再用 edit 追加续块(每块 ≤4KB)逐块落地/)
|
|
519
|
+
// verifier prompt 不动(纪律只约束干活的 Worker)
|
|
520
|
+
const pv = core.buildVerifierPrompt(t)
|
|
521
|
+
assert.doesNotMatch(pv, /大文件交付纪律/)
|
|
522
|
+
assert.doesNotMatch(pv, /每块 ≤4KB/)
|
|
523
|
+
// 源码级:指引段写死在 buildWorkerPrompt 体内(切片边界到 buildVerifierPrompt 之前),不在 buildVerifierPrompt 体内
|
|
524
|
+
const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
|
|
525
|
+
const wBody = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
|
|
526
|
+
assert.ok(wBody.length > 500, 'buildWorkerPrompt 切片成功')
|
|
527
|
+
assert.match(wBody, /大文件交付纪律/)
|
|
528
|
+
assert.match(wBody, /每块 ≤4KB/)
|
|
529
|
+
const vBody = coreSrc.slice(coreSrc.indexOf('export function buildVerifierPrompt'), coreSrc.indexOf('export function buildHookPrompt'))
|
|
530
|
+
assert.doesNotMatch(vBody, /大文件交付纪律/)
|
|
502
531
|
})
|
|
503
532
|
|
|
504
533
|
// ===== 预研上下文段(瘦身分离形态,task-muvjs392):笔记全文 + 文件清单,不含文件内容本体 =====
|
|
@@ -838,6 +867,29 @@ test('pickDispatch: frozen(裁决挂起冻结)不参与任何自动派发',
|
|
|
838
867
|
assert.deepEqual(r2.verifs.map(t => t.id).sort(), ['fv', 'nv'])
|
|
839
868
|
})
|
|
840
869
|
|
|
870
|
+
// ===== 容器卡结构性排除(epic 占 worker 槽根修,用户实证:maxWorkers=3 时一张 =====
|
|
871
|
+
// pipeline=full 的 epic 被派出占槽、已就绪任务空等——此前只靠 pipeline=direct 口头约定)=====
|
|
872
|
+
test('pickDispatch: 有子任务的容器卡(epic)结构性不进派发候选,与 pipeline 无关', () => {
|
|
873
|
+
const epi = mkTask({ id: 'epic', pipeline: 'full' }) // 手滑建 full 档 epic(缺省管线,未传 direct)
|
|
874
|
+
const c1 = mkTask({ id: 'c1', parentId: 'epic' })
|
|
875
|
+
const solo = mkTask({ id: 'solo' }) // 无子任务普通卡照常派发(防回归)
|
|
876
|
+
const r = core.pickDispatch(mkBoard([epi, c1, solo]), 5, 0, null)
|
|
877
|
+
assert.deepEqual(r.pendings.map(t => t.id).sort(), ['c1', 'solo']) // epic 被排除,子任务与普卡照派
|
|
878
|
+
// 子任务口径含已归档(与 childStats 同源:parentId 仍指向即算容器)——全归档的 epic 也不复活进池
|
|
879
|
+
const e2 = mkTask({ id: 'e2', pipeline: 'full' })
|
|
880
|
+
const k1 = mkTask({ id: 'k1', parentId: 'e2', status: 'archived' })
|
|
881
|
+
assert.deepEqual(core.pickDispatch(mkBoard([e2, k1]), 5, 0, null).pendings.map(t => t.id), [])
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
test('pickDispatch: 容器卡排除只挡 Worker 派发——epic 收口转 verifying 后 Verifier 派发不受影响', () => {
|
|
885
|
+
// 既有正确行为:子任务全了结后 epic 转 verifying,派 Verifier 验收是真跑(verifs 分支不动)
|
|
886
|
+
const epi = mkTask({ id: 'epic', status: 'verifying', pipeline: 'full' })
|
|
887
|
+
const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
888
|
+
const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'archived' })
|
|
889
|
+
const r = core.pickDispatch(mkBoard([epi, c1, c2]), 0, 5, null)
|
|
890
|
+
assert.deepEqual(r.verifs.map(t => t.id), ['epic'])
|
|
891
|
+
})
|
|
892
|
+
|
|
841
893
|
// ===== touches 文件级排他:glob 最小匹配器 =====
|
|
842
894
|
test('patOverlap: 完全相同路径冲突 + 归一化(\\ → /、去 ./ 前缀与尾 /)', () => {
|
|
843
895
|
assert.equal(core.patOverlap('src/a.js', 'src/a.js'), true)
|
|
@@ -2748,6 +2800,7 @@ test('README 双份:运行区子会话 id 归属标注 + git 纪律红线各
|
|
|
2748
2800
|
assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
|
|
2749
2801
|
assert.ok(r1.indexOf('对应子代理列表同名条目') >= 0, 'README 缺运行区子会话 id 归属标注说明')
|
|
2750
2802
|
assert.ok(r1.indexOf('git 纪律红线') >= 0, 'README 缺 git 纪律红线说明')
|
|
2803
|
+
assert.ok(r1.indexOf('git add -A') >= 0, 'README 缺精确路径 add 条款说明')
|
|
2751
2804
|
})
|
|
2752
2805
|
|
|
2753
2806
|
// ===== 归档 tab 数据源口径(反馈 n-muyg3e0m9z7i):归档 tab 空 vs 仪表盘 68 根修 =====
|
|
@@ -5697,6 +5750,7 @@ test('高优干预①(continuable):rec 无 run 也走宿主投递——hos
|
|
|
5697
5750
|
assert.equal(r.ok, true)
|
|
5698
5751
|
assert.equal(r.delivered, true, 'continuable rec 干预必须真的送达(旧实现整段被跳过 → delivered 恒 false)')
|
|
5699
5752
|
assert.equal(r.channel, 'steer', 'e2e 断言口径不变:channel 必须仍是 steer')
|
|
5753
|
+
assert.equal(r.state, 'delivered', '实时送达:state=delivered(task-mv6c7cbo 返回体新字段)')
|
|
5700
5754
|
assert.equal(hostCalls.length, 1)
|
|
5701
5755
|
assert.deepEqual(sent, [], 'host-protocol 可用时不再重复走 sendMessage(一次干预只投一次)')
|
|
5702
5756
|
assert.equal(hostCalls[0].p, parent) // 授权者 = 活的直接父 Agent
|
|
@@ -5743,6 +5797,7 @@ test('高优干预②(一次性 rec 逐字不变):agent.steer 优先、ste
|
|
|
5743
5797
|
board.tasks.push(idle)
|
|
5744
5798
|
r = await handlers['intervene-agent']({ taskId: 'iv8', message: '无 run 的干预' })
|
|
5745
5799
|
assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
5800
|
+
assert.equal(r.state, 'no-run', '真无 run:state=no-run(GUI 提示色区分字段,task-mv6c7cbo)')
|
|
5746
5801
|
assert.match(idle.history[idle.history.length - 1].note, /^高优干预: 无 run 的干预(无活跃 run,随下次派发注入)$/)
|
|
5747
5802
|
})
|
|
5748
5803
|
|
|
@@ -5802,6 +5857,57 @@ test('高优干预③(continuable 回退链):host 符号不可用 → send
|
|
|
5802
5857
|
assert.equal(t6.messages.filter((m) => m.kind === 'intervention').length, 1)
|
|
5803
5858
|
})
|
|
5804
5859
|
|
|
5860
|
+
// ===== 送达提示两态细分(task-mv6c7cbo,实证 n-mv65bhk0mg5w:run 已结算 rec 摘除、claimedBy 仍在续跑窗口,
|
|
5861
|
+
// 旧文案「无活跃 run」技术为真但对用户误导——干预实际随续跑注入)=====
|
|
5862
|
+
test('高优干预⑤(续跑窗口细分):无 rec 但续跑资格/认领位还在 → 文案「随续跑注入」+ state=resume-pending', async () => {
|
|
5863
|
+
// 形态 A:run 刚结算留档 incomplete+continuable(resumeTarget 命中),claimedBy 仍指已结算子会话(续跑窗口原形)
|
|
5864
|
+
const resumeTask = mkTask({
|
|
5865
|
+
id: 'iv9', title: '续跑窗口卡', status: 'in-progress', claimedBy: 'child-done',
|
|
5866
|
+
runs: [{ role: 'worker', id: 'child-done', at: '2026-01-01T00:00:00Z', model: '', outcome: 'incomplete', continuable: true }],
|
|
5867
|
+
})
|
|
5868
|
+
// 形态 B:认领位还在但无任何 runs 留档(resumeTarget 空,纯 claimedBy 重排窗口)
|
|
5869
|
+
const claimed = mkTask({ id: 'iv10', title: '重排窗口卡', status: 'in-progress', claimedBy: 'child-x' })
|
|
5870
|
+
const board = mkBoard([resumeTask, claimed])
|
|
5871
|
+
// resumeTarget 走真实函数(与 index.mjs 生产接线同径:从 createDispatch 返回体取出注入 deps)
|
|
5872
|
+
const dsp = mkContinuableDispatch(mkBoard([]))
|
|
5873
|
+
const handlers = mkRpcHandlers(board, { runsFor: () => ({}), resumeTarget: dsp.dispatch.resumeTarget })
|
|
5874
|
+
// A:resumeTarget 命中 → 续跑窗口文案 + state,且不得再出现失真原文「无活跃 run」
|
|
5875
|
+
let r = await handlers['intervene-agent']({ taskId: 'iv9', message: '窗口期干预A' })
|
|
5876
|
+
assert.equal(r.ok, true); assert.equal(r.delivered, false); assert.equal(r.channel, '')
|
|
5877
|
+
assert.equal(r.state, 'resume-pending')
|
|
5878
|
+
const noteA = resumeTask.history[resumeTask.history.length - 1].note
|
|
5879
|
+
assert.match(noteA, /^高优干预: 窗口期干预A(run 刚结算\/续跑窗口,干预将随续跑注入)$/)
|
|
5880
|
+
assert.ok(!/无活跃 run/.test(noteA), '续跑窗口不得再报「无活跃 run」(task-mv6c7cbo 修复的失真原文): ' + noteA)
|
|
5881
|
+
// 干预原文落 messages——续跑指令经 buildMessages 原文带上(「随续跑注入」语义成立的依据)
|
|
5882
|
+
assert.equal(resumeTask.messages.filter((m) => m.kind === 'intervention').length, 1)
|
|
5883
|
+
assert.equal(resumeTask.messages.filter((m) => m.kind === 'intervention')[0].text, '窗口期干预A')
|
|
5884
|
+
// B:claimedBy 窗口(resumeTarget 空)→ 同一文案与 state
|
|
5885
|
+
r = await handlers['intervene-agent']({ taskId: 'iv10', message: '窗口期干预B' })
|
|
5886
|
+
assert.equal(r.delivered, false); assert.equal(r.state, 'resume-pending')
|
|
5887
|
+
assert.match(claimed.history[claimed.history.length - 1].note, /(run 刚结算\/续跑窗口,干预将随续跑注入)$/)
|
|
5888
|
+
// C:deps 未注入 resumeTarget(老宿主/测试桩降级)→ claimedBy 窗口仍命中,语义只降不炸
|
|
5889
|
+
const handlers2 = mkRpcHandlers(board, { runsFor: () => ({}) })
|
|
5890
|
+
r = await handlers2['intervene-agent']({ taskId: 'iv10', message: '窗口期干预C' })
|
|
5891
|
+
assert.equal(r.state, 'resume-pending')
|
|
5892
|
+
assert.match(claimed.history[claimed.history.length - 1].note, /干预将随续跑注入/)
|
|
5893
|
+
})
|
|
5894
|
+
|
|
5895
|
+
// ===== 两态细分接线(源码级):文案/state 字段/透出/接线不被将来改动悄悄抹掉 =====
|
|
5896
|
+
test('高优干预⑥(源码级):续跑窗口两态文案 + state 字段 + resumeTarget 透出与 rpc 接线在位', () => {
|
|
5897
|
+
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
5898
|
+
const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
5899
|
+
const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
|
|
5900
|
+
assert.match(rpcSrc, /(run 刚结算\/续跑窗口,干预将随续跑注入)/) // 续跑窗口文案
|
|
5901
|
+
assert.match(rpcSrc, /(无活跃 run,随下次派发注入)/) // 真无 run 文案不回归
|
|
5902
|
+
assert.match(rpcSrc, /state = 'resume-pending'/)
|
|
5903
|
+
assert.match(rpcSrc, /state = 'no-run'/)
|
|
5904
|
+
assert.match(rpcSrc, /return \{ ok: true, delivered: delivered, channel: channel, state: state \}/) // 返回体带 state
|
|
5905
|
+
assert.match(rpcSrc, /resumeTarget\(t\) !== '' \|\| t\.claimedBy/) // 两态判据(锁内新鲜快照)
|
|
5906
|
+
assert.match(rpcSrc, /var resumeTarget = typeof deps\.resumeTarget === 'function'/) // 缺 dep 降级不炸
|
|
5907
|
+
assert.match(dsp, /resumeTarget: resumeTarget/) // dispatch 返回体透出
|
|
5908
|
+
assert.match(idx, /resumeTarget: dispatch\.resumeTarget/) // 生产接线
|
|
5909
|
+
})
|
|
5910
|
+
|
|
5805
5911
|
// ===== Bug B ④(源码级):投递点接线不被将来改动悄悄抹掉 =====
|
|
5806
5912
|
test('高优干预④(源码级):continuable 投递点 = host-protocol 符号键 + sendMessage 回退,且分路在 rec.continuable 上', () => {
|
|
5807
5913
|
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
@@ -5915,3 +6021,154 @@ test('巡检三连②-③:归档「恢复待办」confirm 门禁(list 不弹
|
|
|
5915
6021
|
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
5916
6022
|
assert.match(built, /window\.confirm\('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token。继续/)
|
|
5917
6023
|
})
|
|
6024
|
+
|
|
6025
|
+
// ===== set-board-config 模型字段校验(task-mv73ym2k,实证 n-mv6ezj6vary4)=====
|
|
6026
|
+
// ① 非法 id 拒写 + 报错带合法清单 ② 合法 id 通过 ③ 空串清除放行 ④ llm 缺失降级放行
|
|
6027
|
+
// ⑤ 熔断通知分态:UNKNOWN_MODEL → 「配置非法(不在可用清单)」≠「验收连续失败」
|
|
6028
|
+
function mkLlmCtx(modelsByProvider) {
|
|
6029
|
+
// modelsByProvider: { providerId: [{id, name}] }——对齐宿主 llm 服务契约(listProviders/listModels)
|
|
6030
|
+
return {
|
|
6031
|
+
get: function (name) {
|
|
6032
|
+
if (name !== 'llm') return null
|
|
6033
|
+
return {
|
|
6034
|
+
listProviders: function () { return Object.keys(modelsByProvider).map(function (id) { return { id: id, name: id } }) },
|
|
6035
|
+
listModels: async function (p) { return modelsByProvider[p] || [] },
|
|
6036
|
+
}
|
|
6037
|
+
},
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
const MOCK_MODELS = { deepseek: [{ id: 'deepseek-chat', name: 'DeepSeek Chat' }, { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner' }], kimi: [{ id: 'kimi-k2', name: 'Kimi K2' }] }
|
|
6041
|
+
|
|
6042
|
+
test('set-board-config 模型校验①:非法 id 拒写 + 报错带合法清单,看板不落脏值', async () => {
|
|
6043
|
+
const board = mkBoard([])
|
|
6044
|
+
const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
|
|
6045
|
+
// 实证原形:显示名「kimi/kimi-k3」抄成 id(合法的是 kimi/kimi-k2)
|
|
6046
|
+
const r = await h['set-board-config']({ key: 'verifierModel', value: 'kimi/kimi-k3' })
|
|
6047
|
+
assert.equal(r.ok, false)
|
|
6048
|
+
assert.match(r.error, /非法模型 id: kimi\/kimi-k3/)
|
|
6049
|
+
assert.match(r.error, /不在当前网关可用清单/)
|
|
6050
|
+
assert.match(r.error, /合法清单(3 个,示前 3 个)/) // 总数 + 截断计数透出
|
|
6051
|
+
assert.match(r.error, /deepseek\/deepseek-chat/) // 清单样例带合法 id
|
|
6052
|
+
assert.match(r.error, /list-models/) // 指引完整清单入口
|
|
6053
|
+
assert.equal(board.verifierModel, undefined) // 拒写:看板字段未被污染
|
|
6054
|
+
// workerModel 同款门禁
|
|
6055
|
+
const r2 = await h['set-board-config']({ key: 'workerModel', value: 'openai/gpt-99' })
|
|
6056
|
+
assert.equal(r2.ok, false)
|
|
6057
|
+
assert.match(r2.error, /非法模型 id/)
|
|
6058
|
+
assert.equal(board.workerModel, undefined)
|
|
6059
|
+
})
|
|
6060
|
+
|
|
6061
|
+
test('set-board-config 模型校验②:合法 id 双字段通过落盘', async () => {
|
|
6062
|
+
const board = mkBoard([])
|
|
6063
|
+
const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
|
|
6064
|
+
const r = await h['set-board-config']({ key: 'verifierModel', value: 'kimi/kimi-k2' })
|
|
6065
|
+
assert.equal(r.ok, true)
|
|
6066
|
+
assert.equal(board.verifierModel, 'kimi/kimi-k2')
|
|
6067
|
+
const r2 = await h['set-board-config']({ key: 'workerModel', value: ' deepseek/deepseek-reasoner ' }) // 首尾空格 trim 后命中
|
|
6068
|
+
assert.equal(r2.ok, true)
|
|
6069
|
+
assert.equal(board.workerModel, 'deepseek/deepseek-reasoner')
|
|
6070
|
+
})
|
|
6071
|
+
|
|
6072
|
+
test('set-board-config 模型校验③:空串/非字符串=清除覆盖,放行不校验', async () => {
|
|
6073
|
+
const board = mkBoard([])
|
|
6074
|
+
board.verifierModel = 'kimi/kimi-k2'
|
|
6075
|
+
const h = mkRpcHandlers(board, {}, mkLlmCtx(MOCK_MODELS))
|
|
6076
|
+
const r = await h['set-board-config']({ key: 'verifierModel', value: '' })
|
|
6077
|
+
assert.equal(r.ok, true)
|
|
6078
|
+
assert.equal(board.verifierModel, '')
|
|
6079
|
+
const r2 = await h['set-board-config']({ key: 'verifierModel', value: undefined })
|
|
6080
|
+
assert.equal(r2.ok, true)
|
|
6081
|
+
assert.equal(board.verifierModel, '')
|
|
6082
|
+
// 非模型键不受枚举影响(无 llm 也照常写)
|
|
6083
|
+
const h2 = mkRpcHandlers(board)
|
|
6084
|
+
const r3 = await h2['set-board-config']({ key: 'maxWorkers', value: 5 })
|
|
6085
|
+
assert.equal(r3.ok, true)
|
|
6086
|
+
assert.equal(board.maxWorkers, 5)
|
|
6087
|
+
})
|
|
6088
|
+
|
|
6089
|
+
test('set-board-config 模型校验④:llm 服务缺失(老宿主)降级放行,不炸', async () => {
|
|
6090
|
+
const board = mkBoard([])
|
|
6091
|
+
// 显式给「有 get 但 llm 缺席」的老宿主形态(mkRpcHandlers 默认 ctx 无 get 方法,生产 ctx.get 恒存在)
|
|
6092
|
+
const hNoLlm = mkRpcHandlers(board, {}, { get: function () { return null } })
|
|
6093
|
+
const r = await hNoLlm['set-board-config']({ key: 'verifierModel', value: 'whatever/display-name' })
|
|
6094
|
+
assert.equal(r.ok, true) // 降级放行:不校验直接写
|
|
6095
|
+
assert.equal(board.verifierModel, 'whatever/display-name')
|
|
6096
|
+
// list-models 在同形态下报服务不可用(既有契约不变)
|
|
6097
|
+
const lm = await hNoLlm['list-models']({})
|
|
6098
|
+
assert.equal(lm.ok, false)
|
|
6099
|
+
assert.match(lm.error, /llm service unavailable/)
|
|
6100
|
+
})
|
|
6101
|
+
|
|
6102
|
+
test('set-board-config 模型校验④b:provider 枚举失败不阻塞,其余 provider 清单仍可校验', async () => {
|
|
6103
|
+
const board = mkBoard([])
|
|
6104
|
+
const ctxExtra = {
|
|
6105
|
+
get: function (name) {
|
|
6106
|
+
if (name !== 'llm') return null
|
|
6107
|
+
return {
|
|
6108
|
+
listProviders: function () { return [{ id: 'broken', name: 'B' }, { id: 'kimi', name: 'K' }] },
|
|
6109
|
+
listModels: async function (p) { if (p === 'broken') throw new Error('gateway boom'); return [{ id: 'kimi-k2' }] },
|
|
6110
|
+
}
|
|
6111
|
+
},
|
|
6112
|
+
}
|
|
6113
|
+
const h = mkRpcHandlers(board, {}, ctxExtra)
|
|
6114
|
+
const rBad = await h['set-board-config']({ key: 'workerModel', value: 'broken/any' }) // 枚举失败的 provider 清单缺席 → 拒
|
|
6115
|
+
assert.equal(rBad.ok, false)
|
|
6116
|
+
assert.match(rBad.error, /kimi\/kimi-k2/)
|
|
6117
|
+
const rOk = await h['set-board-config']({ key: 'workerModel', value: 'kimi/kimi-k2' })
|
|
6118
|
+
assert.equal(rOk.ok, true)
|
|
6119
|
+
assert.equal(board.workerModel, 'kimi/kimi-k2')
|
|
6120
|
+
})
|
|
6121
|
+
|
|
6122
|
+
// 熔断通知分态行为测试 harness:一次性 verifier run 失败,捕获 pushSysNote 文案 + badModels 落账
|
|
6123
|
+
async function runVerifierFuseCase(errText) {
|
|
6124
|
+
const t = mkTask({ id: 'v-fuse', title: '待验收卡', status: 'verifying' })
|
|
6125
|
+
const board = mkBoard([t])
|
|
6126
|
+
board.verifierModel = 'kimi/kimi-k3' // 已写入的(非法)模型覆盖 → rec.model 非空才会触发熔断
|
|
6127
|
+
const notes = []
|
|
6128
|
+
const runs = {}
|
|
6129
|
+
const activeRuns = { [FULL_SID]: runs }
|
|
6130
|
+
const badModels = {}
|
|
6131
|
+
const listeners = []
|
|
6132
|
+
const ctx = {
|
|
6133
|
+
fs: {}, get: function () { return null }, timer: null,
|
|
6134
|
+
effect: function (f) { var d = f(); return function () { if (typeof d === 'function') d() } },
|
|
6135
|
+
on: function (name, fn) { listeners.push({ name: name, fn: fn }); return function () {} },
|
|
6136
|
+
subagents: {
|
|
6137
|
+
list: () => ['mock'],
|
|
6138
|
+
getProvider: () => ({ inheritsParentContext: false }),
|
|
6139
|
+
// spawn 本身成功(半建会话不存在的问题在 provider 层),run.result 随后以给定错误落定失败
|
|
6140
|
+
start: async () => ({ id: 'run-fuse', result: new Promise(function (res, rej) { setImmediate(function () { rej(new Error(errText)) }) }), dispose: async function () {} }),
|
|
6141
|
+
startContinuable: async () => ({ childId: 'child-x', messageId: 'm1' }),
|
|
6142
|
+
},
|
|
6143
|
+
}
|
|
6144
|
+
const dispatch = createDispatch(ctx, { knownSessions: {}, dispatchedEver: {}, badModels: badModels, teamModeCache: {}, activeRuns: activeRuns }, {
|
|
6145
|
+
rt: async () => board, wt: async () => {}, mutateLocked: async (sid, fn) => fn(board), kickCycle: () => {},
|
|
6146
|
+
rootForSession: () => ({ id: FULL_SID }),
|
|
6147
|
+
withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
|
|
6148
|
+
pushSysNote: (sid, text) => { notes.push(text) }, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
6149
|
+
})
|
|
6150
|
+
await dispatch.poolCycle(FULL_SID)
|
|
6151
|
+
assert.ok(runs['v-fuse'], 'verifier run 应已登记活跃表')
|
|
6152
|
+
// 等 run.result 拒绝 → settleRun → settleVerifier 链落定
|
|
6153
|
+
const flushN = () => new Promise(r => setImmediate(r))
|
|
6154
|
+
for (var i = 0; i < 8; i++) await flushN()
|
|
6155
|
+
return { notes: notes, badModels: badModels, task: t }
|
|
6156
|
+
}
|
|
6157
|
+
|
|
6158
|
+
test('模型熔断分态①:UNKNOWN_MODEL → 「配置非法(不在可用清单)」,不误报连续失败', async () => {
|
|
6159
|
+
const r = await runVerifierFuseCase('spawn failed: UNKNOWN_MODEL: kimi/kimi-k3 (model not configured)')
|
|
6160
|
+
assert.equal(r.badModels[FULL_SID + '|kimi/kimi-k3'], true) // 熔断照常落账(下轮回退父级)
|
|
6161
|
+
assert.equal(r.notes.length, 1)
|
|
6162
|
+
assert.match(r.notes[0], /模型 kimi\/kimi-k3 配置非法(不在可用清单),已熔断回退父级模型/)
|
|
6163
|
+
assert.match(r.notes[0], /list-models/) // 指引重配入口
|
|
6164
|
+
assert.ok(!/验收连续失败/.test(r.notes[0]), 'mistake 类不得误报为 quality 类')
|
|
6165
|
+
assert.equal(r.task.verifyRetries, 1) // 重试计数语义不变
|
|
6166
|
+
})
|
|
6167
|
+
|
|
6168
|
+
test('模型熔断分态②:非 UNKNOWN_MODEL 失败 → 保持「验收连续失败」原文案', async () => {
|
|
6169
|
+
const r = await runVerifierFuseCase('provider timeout after 30s')
|
|
6170
|
+
assert.equal(r.badModels[FULL_SID + '|kimi/kimi-k3'], true)
|
|
6171
|
+
assert.equal(r.notes.length, 1)
|
|
6172
|
+
assert.match(r.notes[0], /模型 kimi\/kimi-k3 验收连续失败,已熔断回退父级模型/)
|
|
6173
|
+
assert.ok(!/配置非法/.test(r.notes[0]))
|
|
6174
|
+
})
|