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/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
- // 模型×角色×规模段七指标桶 + 全局质量趋势 rollup;与 usageSummary 同一 range 口径(run 日落点裁剪)。
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
- await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === taskId }); if (t) { if (!Array.isArray(t.messages)) t.messages = []; t.messages.push({ kind: 'intervention', text: msg, at: new Date().toISOString(), by: actor }); ah(t, t.status, t.status, actor, '高优干预: ' + msg.slice(0, 200) + (delivered ? '' : (missedNote || '(无活跃 run,随下次派发注入)'))) }; return t })
644
- return { ok: true, delivered: delivered, channel: channel }
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 时记录随下次派发注入)。', 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) } }))
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
- // 枚举当前网关可用模型(供 Verifier 模型下拉选择):llm.listProviders + listModels
835
- handle('list-models', async function () {
836
- var llm = ctx.get('llm'); if (!llm) return { ok: false, error: 'llm service unavailable' }
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 { ok: true, models: out }
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) { var sid = rpcSessionId(args); 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 } }) })
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 = 模型×角色×规模段七指标桶 + 全局质量趋势 rollup =====
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):七指标桶 + 趋势 rollup + 范围裁剪,每次调用现算,
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.7.9",
4
+ "version": "1.8.0",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
@@ -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 纪律红线(禁还原命令)注入,verifier prompt 不注入(源码断言)', () => {
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
+ })