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 CHANGED
@@ -113,10 +113,10 @@ dsh plugin --profile web remove dsh-agent-board
113
113
 
114
114
  ## 功能总览
115
115
 
116
- **核心能力一页速览**(`★` 为 v1.7.5–1.7.9 新栈):
116
+ **核心能力一页速览**(`★` 为 v1.7.5–1.8.0 新栈):
117
117
 
118
118
  - **看板**:六列状态流、拖拽 / 多选批量 / 文本·优先级·标签筛选、归档留档、会话隔离
119
- - **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管
119
+ - **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管、**spawn 落闸锁内重验**(spawnGateOk:claim 占位与落闸决策读同一把锁的同一份快照,terminate 落在 spawn 在途窗口即弃权——同卡同秒双 run 竞态根治)、**高优干预分态**(intervene 无活跃 run 时区分「续跑窗口,干预随续跑注入」与「真无 run」,返回体带 state 字段)
120
120
  - **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+迟到驳回直投告警,不再静默吞掉)
121
121
  - **度量** `★`:Token 消耗(总量 / 有效 / 缓存读,按模型·按任务·按日)+ 记分卡(模型表现 / 质量趋势 / 驳回聚类)
122
122
  - **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)、**僵尸 run 留档清扫**(host 重启后「落定卡上的 running 无 endedAt 留档」在派发周期自动关账 `stale-closed` + 清认领位,归档门禁不再被幻影 run 永久拦截)
@@ -151,7 +151,7 @@ dsh plugin --profile web remove dsh-agent-board
151
151
  - `get-tasks` 再现算 board 级 `usageSummary`(总量 / 有效合计 / 按模型(合计 `byModel` + 有效分摊 `byModelEff`,两者同源累加)/ Top8(带有效与缓存读拆分)/ 日账双份(范围内 `byDay` + 未过滤全量 `byDayFull`,后者供「今日」「近 7 天」固定口径读取),不落盘额外表;老任务无 `byDay` 时整笔近似归到 `updatedAt` 的本地日,无 `updatedAt` 则不计入任何日)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」;第二参 `range`(可选 `{ from, to }` 本地日)给定即只聚合该范围内的 run(口径见上条)
152
152
  - **主窗口(本会话对话)消耗单列**(`task-muwsol23`):累计行下方新增一行「**主窗口(本会话):今日 X · 累计 Y**」(**总量口径含缓存读**——`task-muxnqunk` 翻转后缓存读不再单列、已含在 total 里,有效值见悬浮 title),按模型分布尾部追加一条「**主窗口(对话)**」(琥珀色与 run 模型区分,总量为 0 不渲染,固定尾部不参与排序);数据源是该看板所属主会话自己的 v4 日志,host 侧**增量尾读**聚合(缓存键=会话 id:文件不变零读、变大只读新增字节段、变小/轮换全量重读一次,尾部半帧不结算下轮补读——3s 轮询稳态零全量重读),五分量与有效口径(e=输入+输出+缓存写)同 Worker 完全一致;**隔离红线:不进 Top 8、不进「本看板累计」、不进架构健康/学习飞轮基数**,只并列展示;随统计范围裁剪(按 `assistant/message` 事件的本地日落点);日志不存在/读取失败/无 usage 一律**静默降级**(不渲染该行,不炸轮询)
153
153
  - 统计口径边界:本区累计 / Top 8 / 架构健康与学习飞轮基数只统计**看板派发的 Worker/Verifier run**,**不含主窗口对话**;主窗口对话消耗按上条单列一行 + 模型分布尾部一条并列展示(不越界混入,UI caption 已明示)
154
- - **「模型表现」区**(记分卡卡3,`task-muxhtgi2`):Token 区下方新增**模型 × 场景七指标对比表**——每行一个 模型 × 角色(worker/verifier)× 规模段(小 <1M / 中 1~10M / 大 >10M,按任务有效 token 分桶)桶;七列口径一句话版:**有效均值** = 桶内 run 有效消耗(输入+输出+缓存写,不含缓存读)的均值、**耗时中位** = run 耗时(endedAt−at)中位数(P90 在行 title)、**驳回率** = 归因到本桶的验收驳回 / 桶内 runs、**一次通过率** = 桶内 resolved 任务中零驳回占比、**超时率** = 超时落定 run / 桶内 runs、**续跑率** = resume 续跑记录 / 桶内 runs、**缓存命中率** = cacheRead/(input+cacheRead)(⚠️ 跨 provider 对比先核 input 是否含缓存读);表头列名均带 title 口径说明;表头下两个**本地筛选器**(角色 全部/worker/verifier + 规模段 全部/小/中/大)只在已返回的 scoreboard 上过滤、**不重拉**;桶内 runs<5 整行**灰显** + title「样本 <5,仅供参考」(不参与任何「最优」强调);无数据显「暂无足够 run 数据」;Token 区 caption 新增「**→ 模型表现**」锚链接一键滚动到位。数据源 = `get-tasks` 响应里的 `usageSummary.scoreboard`(卡2 host 聚合底座 `buildScoreboard`,与 Token 区同一统计范围口径裁剪)
154
+ - **「模型表现」区**(记分卡卡3,`task-muxhtgi2`):Token 区下方新增**模型 × 场景八指标对比表**——每行一个 模型 × 角色(worker/verifier)× 规模段(小 <1M / 中 1~10M / 大 >10M,按任务有效 token 分桶)桶;八列口径一句话版:**有效均值** = 桶内 run 有效消耗(输入+输出+缓存写,不含缓存读)的均值、**耗时中位** = run 耗时(endedAt−at)中位数(P90 在行 title)、**驳回率** = 归因到本桶的验收驳回 / 桶内 runs、**一次通过率** = 桶内 resolved 任务中零驳回占比、**超时率** = 超时落定 run / 桶内 runs、**失败率** = 跑崩落定(incomplete/error/failed)run / 桶内 runs(terminated 人工终止不计入,`task-mv73ym6i`)、**续跑率** = resume 续跑记录 / 桶内 runs、**缓存命中率** = cacheRead/(input+cacheRead)(⚠️ 跨 provider 对比先核 input 是否含缓存读);表头列名均带 title 口径说明;表头下两个**本地筛选器**(角色 全部/worker/verifier + 规模段 全部/小/中/大)只在已返回的 scoreboard 上过滤、**不重拉**;桶内 runs<5 整行**灰显** + title「样本 <5,仅供参考」(不参与任何「最优」强调);无数据显「暂无足够 run 数据」;Token 区 caption 新增「**→ 模型表现**」锚链接一键滚动到位。数据源 = `get-tasks` 响应里的 `usageSummary.scoreboard`(卡2 host 聚合底座 `buildScoreboard`,与 Token 区同一统计范围口径裁剪)
155
155
  - **「质量趋势」区**(记分卡卡4,`task-muxhtgil`):模型表现区下方新增全局趋势四块(P1 质量 KPI 并入)——**一次通过率**(近 14 天柱形:当日落定 resolved 任务中零驳回占比,今日柱高亮 + 块头标今日值,无落定日画灰基线不假装 0%)、**卡时长分布**(四桶横条 <10m / 10-30m / 30-60m / >60m,创建→落定墙钟含排队/验收全程)、**超时率走势**(近 14 天迷你柱行:当日超时落定 run / 当日 runs)、**续跑成功率**(resume 续跑记录中 completed 占比,范围总量口径、host 出参无逐日故不伪造 byDay);全部读 `usageSummary.scoreboard.trends`(卡2 已按统计范围裁剪,client 只渲染不重拉),范围激活时区头带「范围内: …」徽章;近 14 天为固定窗口(范围落在窗口外时对应日柱为空,caption 写明);整区/单块空态均灰显不炸
156
156
  - **「驳回聚类」区**(记分卡卡5,`task-muxhu1zv`):质量趋势区下方新增**驳回原因 Top3**——host 对验收驳回文本做规则法关键词聚类(不上 embedding:驳回文本结构模板化;首命中归类,占比分母=驳回总数,未匹配进「其他」兜底桶;按驳回消息自身本地日落点随统计范围裁剪),每行 TopN 类目 + 占比 + 代表原文(行 title 悬浮,各截 80 字)+ **「建议沉淀为约定」结构化文本**(形如「驳回 Top1『验收脚本未实跑』占 42%——建议在派发约定/Worker prompt 补一条:…」,一键复制按钮,沉淀动作人工);挂 `usageSummary.scoreboard.rejectionClusters`,老 host 缺字段整块不渲染,无驳回显空态。同一通道的**「按表现荐模型」hint**(卡5③):某模型在中小卡 worker 桶合并后 runs≥10 且一次通过率≥90% 且有效均值较其他每个合格模型严格低 >30% → 「架构健康」区亮黄条「模型 X 在中小卡表现最优(通过率 92% · 有效均值 0.8M,较其他合格模型低 35%),建议设为默认 workerModel」;无足够数据/无对比对象不亮
157
157
  - 「架构健康」区(架构自省 L1):`get-tasks` 顺带对**近 50 张卡**现算四信号(纯函数零存储:touches 声明热度 ≥8 次且占比 ≥40% / 带 touches 任务滞留中位数 >2 倍 / 任务**执行**时长 p90 >45min(claimedAt→resolvedAt 纯干活口径,不含排队)/ 同路径驳回 ≥2 次),命中才在仪表盘渲染提示条(⚠️/ℹ️ 两级,最多 3 条)——让运行数据主动提示"该优化架构了"(如某文件反复成为锁热点=该拆),信号只建议不裁判;**运行时健康自检**(`task-muxhrkbg`)复用同一 hint 通道——host 侧在 poolCycle 成功轮 / settle 结算 / 幽灵回收三处记内存心跳(`state.poolHealth`,不落盘):有可派卡+有空位却 >5min 无成功派发轮 → 亮「🔴 派发循环疑似冻结」,有在跑卡却 >30min 无任何结算 → 黄级「结算通道长时间无活动」,幽灵表项回收 >0 时留一条一次性 info 记录(90s 保鲜、读到即灭)——运行事故不再只靠 e2e 翻车与肉眼发现;**质量异动告警**(异常驱动审视②):`usageSummary.scoreboard.trends` 环比——近 7 天一次通过率较再前 7 天跌 >10 个百分点(两侧样本各 ≥5 防小样本误报)→ 黄条「质量异动:一次通过率本周 X%(上周 Y%),建议抽查近期验收」,超时率 / 驳回率突变各加一条同构规则(阈值常量置顶,恰好 10pp 不亮、10.1pp 亮)
@@ -187,7 +187,8 @@ draft → pending → in-progress → verifying → resolved → archived
187
187
  - **依赖调度**:`dependsOn` 声明依赖(DFS 环检测),依赖全部完成后才会被派发,串行链路自动编排
188
188
  - **管线分档**:`full`(执行+验证)/ `work`(只做不验)/ `direct`(不进池,主窗口直接处理),创建时按规则自动分类、可手动覆盖
189
189
  - **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
190
- - **git 纪律红线**:Worker prompt 写死禁止还原命令(`git checkout` / `git restore` / `git reset --hard` / `git clean` 等——会冲掉并行 Worker 与你自己的未提交在途编辑);只改自己写的文件、别人改了别的文件与你无关,确实需要干净基线时走歧义上报(`board_report` kind=escalate)由主窗口裁决
190
+ - **git 纪律红线**:Worker prompt 写死禁止还原命令(`git checkout` / `git restore` / `git reset --hard` / `git clean` 等——会冲掉并行 Worker 与你自己的未提交在途编辑);只改自己写的文件、别人改了别的文件与你无关,确实需要干净基线时走歧义上报(`board_report` kind=escalate)由主窗口裁决;**提交只允许精确路径 `git add`**(本卡 `touches` 声明的文件,或逐一列出明确改过的文件路径),禁止 `git add -A` / `git add .` / `git add 目录`——并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进 commit(实证 n-mv6xntg1p6i5:三起暂存吞并)
191
+ - **大文件交付纪律**:Worker prompt 写死分块写入指引——单文件交付预估 >8KB(或 spec 含全量文案/长代码)时禁止单次 `write` 写入全量(单次超长输出会撞模型输出上限、上下文耗尽零产出),先 `write` 落主体骨架、再用 `edit` 追加续块(每块 ≤4KB)逐块落地
191
192
  - **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁随工作态**:状态流转到已完成(`resolved`)即放锁,`cancelled`/归档同样不再持锁(归档回归纯收纳动作、不再是释放点)——锁只护「正在写」的阶段,「验收后-提交前」的窗口期由主窗口「回执到即提交」纪律 + 史诗 post-hook 承接,不用长持锁把整批串行化。避免并行 Worker 改同一批文件互踩;手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
192
193
  - **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
193
194
  - **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
@@ -199,9 +200,10 @@ draft → pending → in-progress → verifying → resolved → archived
199
200
  - 详情父子区块:父卡详情列子任务清单(状态色点 + 标题,**含已归档留档**——归档行灰化 + 行尾「已归档」徽章并沉底,点击直达子卡详情,标题行汇总 settled/total);子卡详情顶行「↳ 史诗:父标题」点击回跳父卡
200
201
  - **可选 hooks(史诗可选点位:前置/后置各是一次真实 agent 运行)**:`epic.hooks = { pre: { enabled, prompt, state, runId }, post: 同构 }`——**前置**让子任务具备开跑条件(串行闸门:未完成前该史诗的子任务一张都不派),**后置**把这批活收口(全部子任务了结后跑一遍,完成后史诗才转验证中);prompt 只给薄框架契约,具体动作由 hook agent **自行决策**,**吃不准就走歧义上报**(失败即转阻塞等人裁决:重试/跳过/放弃,不自动重跑)。hook run 记在 `epic.runs[]`(role `hook-pre`/`hook-post`),卡片带相位徽章(`⏳ 前置准备中` / `🧪 收尾中` / `⚠️ hook 失败待裁决`,无 hooks 的老卡零渲染),详情页 hooks 区可编辑 enabled+prompt、查看 state/耗时并跳转 run 会话、失败一键跳裁决区;**hooks 仅主窗口可设**(工具与 RPC 双通道门禁),commit/push 不入任何默认形态
201
202
  - **删除通道(真删,无 undo)**:`delete-task` RPC(卡片 hover 垃圾桶按钮 / 详情页「删除」按钮,均先 `confirm('删除不可恢复,确认删除「标题」?')`)+ `batch-op op='delete'`(多选模式底部「批量删除」,同样 confirm)。状态门禁:**草稿/待办/阻塞可删**;进行中/验证中拒绝并提示先用 `terminate-agent` 终止(避免在跑的 run 变孤儿);已完成/取消引导改用归档(`archive-task`,留档可检索);有**未归档子任务**时拒删(防 `parentId` 悬空破坏父任务自动流转);已归档任务幂等返回 ok。是真删(从 `tasks` 数组移除),因此**不产生 `batch-undo` 撤销快照**(批量条对 delete 不显示「↩️ 撤销」),删除操作在 host 端 `console.error` 留一行日志便于溯源
202
- - **任务粒度建议**:单任务 **10~30 分钟**可独立完成为甜区;预计超过 30 分钟的大任务先建一张 **epic 父卡**(`pipeline: direct`,不进池派发),再挂若干 10~30 分钟的子任务(`task_create` 传 `parentId=父卡 id`,有先后顺序用 `dependsOn` 串联),子任务全部完成后父卡自动流转(`checkParentAuto`)——`task_create` 工具描述与 Team 模式提示词都写了这条契约
203
+ - **任务粒度建议**:单任务 **10~30 分钟**可独立完成为甜区;预计超过 30 分钟的大任务先建一张 **epic 父卡**(`pipeline: direct`,不进池派发),再挂若干 10~30 分钟的子任务(`task_create` 传 `parentId=父卡 id`,有先后顺序用 `dependsOn` 串联),子任务全部完成后父卡自动流转(`checkParentAuto`)——`task_create` 工具描述与 Team 模式提示词都写了这条契约;**容器卡结构性不派发**:凡有子任务的卡(epic)永不进派发候选(与 `pipeline` 字段无关,不再只靠 `direct` 口头约定——手滑建 `full` 档 epic 也不会被派出占 worker 槽),子任务全了结转 verifying 后派 Verifier 验收不受影响
203
204
  - **suggestSplit 软提示**:`task_create` / `create-task` 发现描述超 500 字符、或标题/描述命中「全量 / 整体 / 系统级 / 全面 / 重构 / 所有模块 / 整个」等史诗特征词时,返回体附带一行 `suggestSplit` 建议文案(**只提示,不阻断创建与派发**;未命中则不出现该字段,老调用方无感)
204
205
  - **开关 `epicSplit`(史诗拆分总开关)**(⚙️ 入池配置弹层「**功能**」小节,默认**开**):关掉只停**引导**——Team 提示词第 6 条拆分条款整条不注入、`create-task`/`task_create` 响应不再附 `suggestSplit` 软提示;**机制不禁**(显式传 `parentId` 建子卡、史诗自动收口与 hooks 状态机照常工作,明确要拆时不受阻);老看板文件没有该字段 → 读路径自动补 `true`(与升级前行为一致)。工具描述里的粒度契约是静态工具定义(随 request header 快照),不在开关范围
206
+ - **模型字段写保护**:`set-board-config` 对 `workerModel`/`verifierModel` 的非空值校验当前网关可用清单(`list-models` 同款枚举)——手写非法 id(如把熔断通知里的显示名抄成 id)直接拒写并返回合法清单前 8 个,验收 spawn 不再 UNKNOWN_MODEL 秒死;空串=清除放行;宿主无 llm 服务时降级跳过校验不炸。配套熔断通知分态:`UNKNOWN_MODEL` 类失败报「配置非法(不在可用清单)」,与「验收连续失败」区分——mistake 归 mistake,quality 归 quality
205
207
 
206
208
  ### 一次性派发(v74 去池化)
207
209
 
@@ -325,7 +327,7 @@ dsh web 的 client 运行时不具备模块解析能力(entry 被整体读成
325
327
  | `kernel.js` | 底座(用户不可感知) | 渲染原语(ic/icText/ActorLink)、RPC 封装与轮询族、共享状态(state/listeners/notify)、图标(ICONS)、共享任务工具,以及面板骨架入口(BoardButton/ViewTab/TopPanel/slots.inject) |
326
328
  | `board-list.js` | 看板列表 | 看板列与卡片(Card)、筛选条、多选批量操作条、建卡表单、归档列表 |
327
329
  | `task-detail.js` | 任务详情 | 详情抽屉(编辑/流转/冻结/touches/历史会话/高优介入)、歧义裁决对话与候选教训沉淀、依赖区块 |
328
- | `dashboard.js` | 仪表盘与设置 | 统计图表、Token 消耗、模型表现(七指标×场景筛选)、质量趋势(通过率/时长分布/超时/续跑)、全局总览、报告生成、团队池视图、池/模型/超时设置与工作模式开关 |
330
+ | `dashboard.js` | 仪表盘与设置 | 统计图表、Token 消耗、模型表现(八指标×场景筛选)、质量趋势(通过率/时长分布/超时/续跑)、全局总览、报告生成、团队池视图、池/模型/超时设置与工作模式开关 |
329
331
 
330
332
  四个模块拼进同一个 `apply(ctx)` 函数作用域(`var`/`function` 声明提升使跨模块引用与拼接顺序无关;
331
333
  所有同步执行代码——DOM 监听、轮询注册、布局同步、slots.inject——都在 kernel 域内保持原相对顺序)。
package/index.mjs CHANGED
@@ -85,6 +85,7 @@ export function apply(ctx) {
85
85
  pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
86
86
  spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
87
87
  closeRunHistory: dispatch.closeRunHistory, // 手动终止 run 关账(doTerminate 状态无关清理,task-mv1pqy27)
88
+ resumeTarget: dispatch.resumeTarget, // doIntervene 无活跃 rec 时细分续跑窗口/真无 run(task-mv6c7cbo)
88
89
  // 上报通道结算入口(task-muwkhqf8):board_report/board_verdict 把任务推进到落定态时,
89
90
  // 由 rpc 侧顺手对「该任务当前的 continuable Worker rec」补做收尾三件套
90
91
  // (关 run 结局 / usage 落账 / 摘超时臂)——治「工具上报完成后 idle 事件没到 → run 永远 running」。
@@ -471,21 +471,21 @@
471
471
  // 否则「选了范围数字没变」看起来像 bug。模型分布/Top8/累计才是范围生效的三处。
472
472
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:累计与 Top 8 仅看板派发的 Worker/Verifier run(= 一次执行)消耗;主窗口行=本会话对话消耗,与看板派发口径并列不混入(单列一行 + 模型分布尾部一条,不进累计 / Top 8);主数字(今日 / 近 7 天 / 模型分布 / Top 8 / 主窗口行)均为总量口径(含缓存读);有效消耗(input+output+cacheWrite,不含缓存读)见悬浮 title'),
473
473
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2, lineHeight: 1.5 } }, '统计范围作用于按模型分布 / 任务消耗 Top 8 / 累计三分量(按 run 的本地日落点过滤);今日与「近 7 天」为固定口径,不随范围变化。'),
474
- // 「→ 模型表现」滚动锚链接(task-muxhtgi2 卡3):一键滚到下方模型×场景七指标表
474
+ // 「→ 模型表现」滚动锚链接(task-muxhtgi2 卡3):一键滚到下方模型×场景八指标表
475
475
  // (与详情页 goArbitration 同一 scrollIntoView 原语;目标区空态也挂锚 id,链接始终可达)
476
476
  React.createElement('div', { style: { fontSize: 9, marginTop: 4 } },
477
- React.createElement('span', { onClick: function () { var el = document.getElementById('tskb-model-perf'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }) }, style: { color: C.brand, cursor: 'pointer' }, title: '滚动到下方「模型表现」区:模型 × 场景(角色 × 规模段)七指标对比表' }, '→ 模型表现')))
477
+ React.createElement('span', { onClick: function () { var el = document.getElementById('tskb-model-perf'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }) }, style: { color: C.brand, cursor: 'pointer' }, title: '滚动到下方「模型表现」区:模型 × 场景(角色 × 规模段)八指标对比表' }, '→ 模型表现')))
478
478
  }
479
479
 
480
- // ===== 模型表现区(记分卡卡3,task-muxhtgi2):七指标表格 × 场景筛选器(本地过滤不重拉)=====
480
+ // ===== 模型表现区(记分卡卡3,task-muxhtgi2):八指标表格 × 场景筛选器(本地过滤不重拉)=====
481
481
  // 数据源:state.usageSummary.scoreboard——卡2 host 聚合底座(buildScoreboard)挂进 get-tasks 响应,
482
482
  // 与 Token 区同通道透传、同一 range 口径(范围切换重拉后随 notify 同步刷新,本区零自持请求)。
483
483
  // 每桶 = 模型 × 角色(worker/verifier)× 规模段(small/medium/large,按任务有效 token 分桶)。
484
484
  // 本区三件事全部本地现算:
485
485
  // ① 场景筛选器:角色(全部/worker/verifier)+ 规模段(全部/小/中/大)——scoreboard 已在响应里,
486
486
  // 筛选只是 buckets 数组 pass,绝不重拉(组件内无 rpc/fetchTasks);
487
- // ② 七指标表格:每 模型×场景桶 一行,七列——有效均值/耗时中位/驳回率/一次通过率/超时率/续跑率/
488
- // 缓存命中率;表头列名带 title 口径说明(与卡2 host 注释同源);
487
+ // ② 八指标表格:每 模型×场景桶 一行,八列——有效均值/耗时中位/驳回率/一次通过率/超时率/失败率/
488
+ // 续跑率/缓存命中率;表头列名带 title 口径说明(与卡2 host 注释同源);
489
489
  // ③ 样本护栏:insufficient(runs<5)行整行灰显 + title 前缀「样本 <5,仅供参考」——
490
490
  // 仅供参考,不参与任何「最优」视觉强调(本区 v1 不做任何高亮排名,从源头规避)。
491
491
  // 空态:scoreboard 缺字段 / buckets 空 → 「暂无足够 run 数据」(锚 id 照挂,Token 区链接始终可达);
@@ -495,13 +495,16 @@
495
495
  // 率类/耗时格式化:null = 无样本(不假装 0,与 host 同哲学)
496
496
  function mpPct(r) { return (r === null || r === undefined || !isFinite(r)) ? '—' : Math.round(r * 100) + '%' }
497
497
  function mpDur(ms) { if (ms === null || ms === undefined || !isFinite(ms)) return '—'; var m = Math.round(ms / 60000); if (m < 1) return '<1m'; if (m < 60) return m + 'm'; return (m / 60).toFixed(1) + 'h' }
498
- // 七列定义:label + title 口径说明 + 取值格式化(口径一句话版与 README 仪表盘段同步维护)
498
+ // 八列定义:label + title 口径说明 + 取值格式化(口径一句话版与 README 仪表盘段同步维护)
499
499
  var MP_COLS = [
500
500
  { k: 'effAvg', label: '有效均值', title: '有效 token 均值 = 桶内 run 有效消耗(输入+输出+缓存写,不含缓存读)的均值;无 usage 留账的 run 不进均值(不伪造 0)', fmt: function (b) { return b.effAvg === null ? '—' : fmtTokens(b.effAvg) } },
501
501
  { k: 'durMedianMs', label: '耗时中位', title: 'run 耗时(endedAt - at)中位数;P90 见行 title;无合法时刻的 run 不进分布', fmt: function (b) { return mpDur(b.durMedianMs) } },
502
502
  { k: 'rejectRate', label: '驳回率', title: '驳回率 = 归因到本桶 run 的验收驳回次数 / 桶内 runs(归因 = 驳回前最近 worker run)', fmt: function (b) { return mpPct(b.rejectRate) } },
503
503
  { k: 'firstPassRate', label: '一次通过率', title: '一次通过率 = 桶内 resolved 任务中 rejectCount=0(零驳回)的占比;桶内无 resolved 任务 → —', fmt: function (b) { return mpPct(b.firstPassRate) } },
504
504
  { k: 'timeoutRate', label: '超时率', title: '超时率 = outcome 超时落定(timeout/error)的 run / 桶内 runs', fmt: function (b) { return mpPct(b.timeoutRate) } },
505
+ // 失败率列(task-mv73ym6i):跑崩的 run 不再隐形——incomplete(猝死/未产出结算)/error/failed
506
+ // 计入;terminated 人工终止不计入(人工动作非模型表现);超时落定走「超时率」列不重复计。
507
+ { k: 'failRate', label: '失败率', title: '失败率 = 跑崩落定(outcome 为 incomplete/error/failed)的 run / 桶内 runs;terminated 人工终止不计入(人工动作非模型表现);超时落定走「超时率」列,不重复计', fmt: function (b) { return mpPct(b.failRate) } },
505
508
  { k: 'resumeRate', label: '续跑率', title: '续跑率 = resume:true 续跑记录 / 桶内 runs', fmt: function (b) { return mpPct(b.resumeRate) } },
506
509
  { k: 'cacheHitRate', label: '缓存命中率', title: '缓存命中率 = cacheRead / (input + cacheRead);⚠️ 跨 provider 口径可能不一(input 是否含缓存读各家不同),对比前先核口径;无 usage 样本 → —', fmt: function (b) { return mpPct(b.cacheHitRate) } }
507
510
  ]
@@ -551,14 +554,14 @@
551
554
  MP_COLS.map(function (c) { return React.createElement('th', { key: c.k, style: thR, title: c.title }, c.label) }))),
552
555
  React.createElement('tbody', null, shown.map(function (b) {
553
556
  // 样本护栏:insufficient 行整行灰显 + title 前缀「样本 <5,仅供参考」,不进任何最优强调
554
- var tip = (b.insufficient ? '样本 <5,仅供参考——' : '') + b.model + ' × ' + b.role + ' × ' + (MP_SIZE_LABEL[b.size] || b.size) + ':runs=' + b.runs + ';有效总额 ' + fmtTokens(b.effSum) + ' tok;耗时 P90 ' + mpDur(b.durP90Ms) + '(七指标口径见表头 title)'
557
+ var tip = (b.insufficient ? '样本 <5,仅供参考——' : '') + b.model + ' × ' + b.role + ' × ' + (MP_SIZE_LABEL[b.size] || b.size) + ':runs=' + b.runs + ';有效总额 ' + fmtTokens(b.effSum) + ' tok;耗时 P90 ' + mpDur(b.durP90Ms) + '(八指标口径见表头 title)'
555
558
  return React.createElement('tr', { key: b.model + '|' + b.role + '|' + b.size, title: tip, style: { opacity: b.insufficient ? 0.45 : 1 } },
556
559
  React.createElement('td', { style: tdL }, b.model),
557
560
  React.createElement('td', { style: tdL }, b.role + '·' + (MP_SIZE_LABEL[b.size] || b.size)),
558
561
  React.createElement('td', { style: tdR }, String(b.runs)),
559
562
  MP_COLS.map(function (c) { return React.createElement('td', { key: c.k, style: tdR }, c.fmt(b)) }))
560
563
  })))),
561
- React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:每行 = 模型 × 角色 × 规模段桶(规模按任务有效 token:小 <1M / 中 1~10M / 大 >10M);率类分母为桶内 runs,一次通过率分母为桶内 resolved 任务;runs<5 行灰显 = 样本不足仅供参考;随统计范围裁剪(与 Token 区同一 range)'))
564
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:每行 = 模型 × 角色 × 规模段桶(规模按任务有效 token:小 <1M / 中 1~10M / 大 >10M);率类分母为桶内 runs,一次通过率分母为桶内 resolved 任务;失败率 = 跑崩落定(outcome 为 incomplete/error/failed)的 run / 桶内 runs,terminated 人工终止不计入;runs<5 行灰显 = 样本不足仅供参考;随统计范围裁剪(与 Token 区同一 range)'))
562
565
  }
563
566
 
564
567
  // ===== 质量趋势区(记分卡卡4,task-muxhtgil):一次通过率趋势 / 卡时长分布 / 超时率走势 / 续跑成功率 =====
package/lib/client.js CHANGED
@@ -2008,21 +2008,21 @@ function apply(ctx) {
2008
2008
  // 否则「选了范围数字没变」看起来像 bug。模型分布/Top8/累计才是范围生效的三处。
2009
2009
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:累计与 Top 8 仅看板派发的 Worker/Verifier run(= 一次执行)消耗;主窗口行=本会话对话消耗,与看板派发口径并列不混入(单列一行 + 模型分布尾部一条,不进累计 / Top 8);主数字(今日 / 近 7 天 / 模型分布 / Top 8 / 主窗口行)均为总量口径(含缓存读);有效消耗(input+output+cacheWrite,不含缓存读)见悬浮 title'),
2010
2010
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2, lineHeight: 1.5 } }, '统计范围作用于按模型分布 / 任务消耗 Top 8 / 累计三分量(按 run 的本地日落点过滤);今日与「近 7 天」为固定口径,不随范围变化。'),
2011
- // 「→ 模型表现」滚动锚链接(task-muxhtgi2 卡3):一键滚到下方模型×场景七指标表
2011
+ // 「→ 模型表现」滚动锚链接(task-muxhtgi2 卡3):一键滚到下方模型×场景八指标表
2012
2012
  // (与详情页 goArbitration 同一 scrollIntoView 原语;目标区空态也挂锚 id,链接始终可达)
2013
2013
  React.createElement('div', { style: { fontSize: 9, marginTop: 4 } },
2014
- React.createElement('span', { onClick: function () { var el = document.getElementById('tskb-model-perf'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }) }, style: { color: C.brand, cursor: 'pointer' }, title: '滚动到下方「模型表现」区:模型 × 场景(角色 × 规模段)七指标对比表' }, '→ 模型表现')))
2014
+ React.createElement('span', { onClick: function () { var el = document.getElementById('tskb-model-perf'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }) }, style: { color: C.brand, cursor: 'pointer' }, title: '滚动到下方「模型表现」区:模型 × 场景(角色 × 规模段)八指标对比表' }, '→ 模型表现')))
2015
2015
  }
2016
2016
 
2017
- // ===== 模型表现区(记分卡卡3,task-muxhtgi2):七指标表格 × 场景筛选器(本地过滤不重拉)=====
2017
+ // ===== 模型表现区(记分卡卡3,task-muxhtgi2):八指标表格 × 场景筛选器(本地过滤不重拉)=====
2018
2018
  // 数据源:state.usageSummary.scoreboard——卡2 host 聚合底座(buildScoreboard)挂进 get-tasks 响应,
2019
2019
  // 与 Token 区同通道透传、同一 range 口径(范围切换重拉后随 notify 同步刷新,本区零自持请求)。
2020
2020
  // 每桶 = 模型 × 角色(worker/verifier)× 规模段(small/medium/large,按任务有效 token 分桶)。
2021
2021
  // 本区三件事全部本地现算:
2022
2022
  // ① 场景筛选器:角色(全部/worker/verifier)+ 规模段(全部/小/中/大)——scoreboard 已在响应里,
2023
2023
  // 筛选只是 buckets 数组 pass,绝不重拉(组件内无 rpc/fetchTasks);
2024
- // ② 七指标表格:每 模型×场景桶 一行,七列——有效均值/耗时中位/驳回率/一次通过率/超时率/续跑率/
2025
- // 缓存命中率;表头列名带 title 口径说明(与卡2 host 注释同源);
2024
+ // ② 八指标表格:每 模型×场景桶 一行,八列——有效均值/耗时中位/驳回率/一次通过率/超时率/失败率/
2025
+ // 续跑率/缓存命中率;表头列名带 title 口径说明(与卡2 host 注释同源);
2026
2026
  // ③ 样本护栏:insufficient(runs<5)行整行灰显 + title 前缀「样本 <5,仅供参考」——
2027
2027
  // 仅供参考,不参与任何「最优」视觉强调(本区 v1 不做任何高亮排名,从源头规避)。
2028
2028
  // 空态:scoreboard 缺字段 / buckets 空 → 「暂无足够 run 数据」(锚 id 照挂,Token 区链接始终可达);
@@ -2032,13 +2032,16 @@ function apply(ctx) {
2032
2032
  // 率类/耗时格式化:null = 无样本(不假装 0,与 host 同哲学)
2033
2033
  function mpPct(r) { return (r === null || r === undefined || !isFinite(r)) ? '—' : Math.round(r * 100) + '%' }
2034
2034
  function mpDur(ms) { if (ms === null || ms === undefined || !isFinite(ms)) return '—'; var m = Math.round(ms / 60000); if (m < 1) return '<1m'; if (m < 60) return m + 'm'; return (m / 60).toFixed(1) + 'h' }
2035
- // 七列定义:label + title 口径说明 + 取值格式化(口径一句话版与 README 仪表盘段同步维护)
2035
+ // 八列定义:label + title 口径说明 + 取值格式化(口径一句话版与 README 仪表盘段同步维护)
2036
2036
  var MP_COLS = [
2037
2037
  { k: 'effAvg', label: '有效均值', title: '有效 token 均值 = 桶内 run 有效消耗(输入+输出+缓存写,不含缓存读)的均值;无 usage 留账的 run 不进均值(不伪造 0)', fmt: function (b) { return b.effAvg === null ? '—' : fmtTokens(b.effAvg) } },
2038
2038
  { k: 'durMedianMs', label: '耗时中位', title: 'run 耗时(endedAt - at)中位数;P90 见行 title;无合法时刻的 run 不进分布', fmt: function (b) { return mpDur(b.durMedianMs) } },
2039
2039
  { k: 'rejectRate', label: '驳回率', title: '驳回率 = 归因到本桶 run 的验收驳回次数 / 桶内 runs(归因 = 驳回前最近 worker run)', fmt: function (b) { return mpPct(b.rejectRate) } },
2040
2040
  { k: 'firstPassRate', label: '一次通过率', title: '一次通过率 = 桶内 resolved 任务中 rejectCount=0(零驳回)的占比;桶内无 resolved 任务 → —', fmt: function (b) { return mpPct(b.firstPassRate) } },
2041
2041
  { k: 'timeoutRate', label: '超时率', title: '超时率 = outcome 超时落定(timeout/error)的 run / 桶内 runs', fmt: function (b) { return mpPct(b.timeoutRate) } },
2042
+ // 失败率列(task-mv73ym6i):跑崩的 run 不再隐形——incomplete(猝死/未产出结算)/error/failed
2043
+ // 计入;terminated 人工终止不计入(人工动作非模型表现);超时落定走「超时率」列不重复计。
2044
+ { k: 'failRate', label: '失败率', title: '失败率 = 跑崩落定(outcome 为 incomplete/error/failed)的 run / 桶内 runs;terminated 人工终止不计入(人工动作非模型表现);超时落定走「超时率」列,不重复计', fmt: function (b) { return mpPct(b.failRate) } },
2042
2045
  { k: 'resumeRate', label: '续跑率', title: '续跑率 = resume:true 续跑记录 / 桶内 runs', fmt: function (b) { return mpPct(b.resumeRate) } },
2043
2046
  { k: 'cacheHitRate', label: '缓存命中率', title: '缓存命中率 = cacheRead / (input + cacheRead);⚠️ 跨 provider 口径可能不一(input 是否含缓存读各家不同),对比前先核口径;无 usage 样本 → —', fmt: function (b) { return mpPct(b.cacheHitRate) } }
2044
2047
  ]
@@ -2088,14 +2091,14 @@ function apply(ctx) {
2088
2091
  MP_COLS.map(function (c) { return React.createElement('th', { key: c.k, style: thR, title: c.title }, c.label) }))),
2089
2092
  React.createElement('tbody', null, shown.map(function (b) {
2090
2093
  // 样本护栏:insufficient 行整行灰显 + title 前缀「样本 <5,仅供参考」,不进任何最优强调
2091
- var tip = (b.insufficient ? '样本 <5,仅供参考——' : '') + b.model + ' × ' + b.role + ' × ' + (MP_SIZE_LABEL[b.size] || b.size) + ':runs=' + b.runs + ';有效总额 ' + fmtTokens(b.effSum) + ' tok;耗时 P90 ' + mpDur(b.durP90Ms) + '(七指标口径见表头 title)'
2094
+ var tip = (b.insufficient ? '样本 <5,仅供参考——' : '') + b.model + ' × ' + b.role + ' × ' + (MP_SIZE_LABEL[b.size] || b.size) + ':runs=' + b.runs + ';有效总额 ' + fmtTokens(b.effSum) + ' tok;耗时 P90 ' + mpDur(b.durP90Ms) + '(八指标口径见表头 title)'
2092
2095
  return React.createElement('tr', { key: b.model + '|' + b.role + '|' + b.size, title: tip, style: { opacity: b.insufficient ? 0.45 : 1 } },
2093
2096
  React.createElement('td', { style: tdL }, b.model),
2094
2097
  React.createElement('td', { style: tdL }, b.role + '·' + (MP_SIZE_LABEL[b.size] || b.size)),
2095
2098
  React.createElement('td', { style: tdR }, String(b.runs)),
2096
2099
  MP_COLS.map(function (c) { return React.createElement('td', { key: c.k, style: tdR }, c.fmt(b)) }))
2097
2100
  })))),
2098
- React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:每行 = 模型 × 角色 × 规模段桶(规模按任务有效 token:小 <1M / 中 1~10M / 大 >10M);率类分母为桶内 runs,一次通过率分母为桶内 resolved 任务;runs<5 行灰显 = 样本不足仅供参考;随统计范围裁剪(与 Token 区同一 range)'))
2101
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:每行 = 模型 × 角色 × 规模段桶(规模按任务有效 token:小 <1M / 中 1~10M / 大 >10M);率类分母为桶内 runs,一次通过率分母为桶内 resolved 任务;失败率 = 跑崩落定(outcome 为 incomplete/error/failed)的 run / 桶内 runs,terminated 人工终止不计入;runs<5 行灰显 = 样本不足仅供参考;随统计范围裁剪(与 Token 区同一 range)'))
2099
2102
  }
2100
2103
 
2101
2104
  // ===== 质量趋势区(记分卡卡4,task-muxhtgil):一次通过率趋势 / 卡时长分布 / 超时率走势 / 续跑成功率 =====
package/lib/core.mjs CHANGED
@@ -767,7 +767,15 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
767
767
  if (feedbackEnabled !== false) p += '\n\n' + LESSON_RECALL_HINT
768
768
  // git 纪律红线(n-muyaaoo7cqnd 实证事故:并行 Worker 跑 git checkout HEAD -- package.json
769
769
  // 把另一 Worker 的在途编辑冲掉):禁止还原命令,只改自己写的文件,要干净基线走 escalate 上报。
770
- p += '\n\ngit 纪律红线:禁止 git checkout / git restore / git reset --hard / git clean 等还原命令(会冲掉并行 Worker 与你自己的未提交在途编辑);你只需管自己写的文件,别人改了别的文件与你无关;确实需要干净基线时用 board_report(kind=escalate)上报,由主窗口裁决。'
770
+ // 精确路径 add 条款(n-mv6xntg1p6i5 实证:并行 Worker 共享 worktree+暂存区,宽泛 git add
771
+ // 三起吞并他卡在途编辑——p4-rdf 带走 p4-apply-ddl-core 的 4 处在途编辑混入自己 commit):
772
+ // 提交只允许精确路径 add(本卡 touches 或逐一列明改过的文件),禁 git add -A / . / 目录。
773
+ p += '\n\ngit 纪律红线:禁止 git checkout / git restore / git reset --hard / git clean 等还原命令(会冲掉并行 Worker 与你自己的未提交在途编辑);你只需管自己写的文件,别人改了别的文件与你无关;确实需要干净基线时用 board_report(kind=escalate)上报,由主窗口裁决。提交时只允许精确路径 git add <本卡 touches 声明的文件>(或明确你改过的文件路径逐一列出);禁止 git add -A / git add . / git add 目录——并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进你的 commit。'
774
+ // 大文件交付纪律(实证 n-mv65bhk0mg5w:gs-gov-policy task-mv63fo9y 池 Worker 连续 3 个 run 单次输出
775
+ // 64k tok 撞上限、烧 330k token 零文件产出——都在试图一次性 write 整个 22.5KB HTML;主窗口改用
776
+ // write×1 + edit×2 分块落地同一 spec 一次过):固定段写死「预估 >8KB 禁止单次 write 全量」。
777
+ // 纯 prompt 层指引段,零逻辑风险;只进 Worker prompt,不进 verifier/hook(与 git 纪律段同例)。
778
+ p += '\n\n大文件交付纪律:单文件交付预估 >8KB(或 spec 含全量文案/长代码)时,禁止单次 write 写入全量——单次超长输出会撞模型输出上限、上下文耗尽零产出。先 write 落主体骨架,再用 edit 追加续块(每块 ≤4KB)逐块落地,直至写完。'
771
779
  p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。\n3. 进展汇报(较大任务):按里程碑推进,每完成一个可验证的里程碑调用一次 board_report(kind="progress", taskId=' + t.id + ', question=一行进展摘要,≤200 字符)。只在有实际产物/结论时报;禁止定时汇报或表演式汇报。'
772
780
  return p
773
781
  }
@@ -911,6 +919,11 @@ export function touchesConflict(t, holds) {
911
919
  // pre hook 串行闸门(hooks=agent run 接线①):候选所属 epic 的 hooks.pre 未 done 时整批不派
912
920
  // (见 preHookGate),由 poolCycle 改 spawn hook-pre run——同一 epic 的子任务在准备完成前
913
921
  // 一张都不派,这是「pre 闸门」的串行语义;未声明 hooks 的 epic 逐字不受影响。
922
+ // 容器卡结构性排除(epic 占 worker 槽根修):凡有子任务的卡(epic)永不进派发候选——
923
+ // 与 pipeline 字段无关,不再只靠「pipeline=direct 口头约定」(task_create 缺省 full,
924
+ // 手滑忘传 direct 的 epic 进池被派出占槽、已就绪任务空等,用户实证)。verifs 分支不动:
925
+ // 子任务全了结后 epic 转 verifying 派 Verifier 验收是既有正确行为,不受此排除影响。
926
+ // 子任务口径与 childStats 同源:含已归档(parentId 仍指向即算有子任务)。
914
927
  export function pickDispatch(d, capW, capV, busyTaskIds) {
915
928
  var blockedTouches = []
916
929
  var pendings = []
@@ -918,7 +931,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
918
931
  // 防止同一轮 cycle 派出的两个任务声明重叠 touches。
919
932
  var holds = holdsFiles(d)
920
933
  if (capW > 0) {
921
- var cands = d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && !t.frozen && t.assignMode !== 'manual' && t.pipeline !== 'direct' && depsSatisfied(d, t) && !t.escalation && preHookGate(gpt(t, d.tasks)).pass })
934
+ var cands = d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && !t.frozen && t.assignMode !== 'manual' && t.pipeline !== 'direct' && !d.tasks.some(function (x) { return x.parentId === t.id }) && depsSatisfied(d, t) && !t.escalation && preHookGate(gpt(t, d.tasks)).pass })
922
935
  .sort(function (a, b) { var p = (PRIO_RANK[b.priority] || 2) - (PRIO_RANK[a.priority] || 2); return p !== 0 ? p : (a.createdAt || '').localeCompare(b.createdAt || '') })
923
936
  for (var i = 0; i < cands.length && pendings.length < capW; i++) {
924
937
  var conflicts = touchesConflict(cands[i], holds)
package/lib/dispatch.mjs CHANGED
@@ -174,6 +174,34 @@ export function createDispatch(ctx, state, deps) {
174
174
  try { var log = findRunLog(childId, sessionsRoot); if (!log) return 0; return statSync(log).size || 0 } catch (_) { return 0 }
175
175
  }
176
176
 
177
+ // ===== spawn 落闸锁内重验(task-mv6c7cdm:terminate→重派双 run 竞态根治,实证 n-mv65bhk0mg5w)=====
178
+ // 竞态原形:claim 占位在锁内、spawn 执行在锁外,中间隔着 sendMessage 冷复活 / startContinuable 的
179
+ // 慢 await——terminate 落进这个窗口时在活跃表里找不到在途 spawn(rec 尚未注册),interrupt 打不到
180
+ // 它;卡面已被翻回 pending,spawn 返回后照旧落闸 → 同卡同秒双 run(旧子复活 + 新子 fresh spawn
181
+ // 各自烧上下文后报丢)。红线:落闸必须以**锁内新鲜快照**重验本轮 claim 仍有效(与 claim CAS 同
182
+ // 一把锁、同一份文档),不能只信锁外快照:
183
+ // · worker 一律验:卡仍 in-progress 且认领位仍是本轮占位 'spawn-pending'(terminate 会清占位并
184
+ // 翻 pending,两个条件任一失守都说明本轮 claim 已作废);
185
+ // · 续跑追加验:锁内重算的 resumeTarget 仍指向同一个 childId(terminate 命中活跃 rec 时会把
186
+ // 留档关成 'terminated',续跑资格随之失效——锁内重算天然覆盖这条)。
187
+ // 用法契约:spawn 返回后**零 await 先登记 rec**(保住事件可见性既有纪律),再过本闸门;
188
+ // 闸门否决时由调用方逆向摘干净(settled 旗 + 摘 rec + 停子会话)。这样 terminate 只剩两类落点:
189
+ // 落在 spawn 在途期(当时无 rec,闸门读锁内快照发现卡面已翻走 → 否决)/ 落在登记之后(命中
190
+ // 活跃 rec,走 terminate 自己的正常 interrupt+关账路径)——不再存在「在途 spawn 看不见」的
191
+ // 第三类窗口。纯验证:回调恒返回 null → 不写盘不 kick(mutateLocked 契约);
192
+ // 闸门自身异常按弃权处理(宁停勿双——占位遗留由 2min 占位超时回收兜底,下轮重派)。
193
+ async function spawnGateOk(sid, taskId, resumeId) {
194
+ var ok = false
195
+ try {
196
+ await mutateLocked(sid, function (d) {
197
+ var t = d.tasks.find(function (x) { return x.id === taskId })
198
+ ok = !!(t && t.status === 'in-progress' && t.claimedBy === 'spawn-pending' && (!resumeId || resumeTarget(t) === resumeId))
199
+ return null
200
+ }, true)
201
+ } catch (e) { console.error('[task-board] spawn 闸门重验异常(按弃权处理,宁停勿双)task ' + taskId + ':', String(e)); ok = false }
202
+ return ok
203
+ }
204
+
177
205
  async function spawnOneShot(sid, t, role) {
178
206
  var subagents = ctx.subagents; if (!subagents) return null
179
207
  // 兜底保留(真异常时有用):poolCycle 入口已有 root 存活早闸门,正常派发路径不会到达这里;
@@ -262,9 +290,33 @@ export function createDispatch(ctx, state, deps) {
262
290
  var rec = useContinuable
263
291
  ? { id: childId, childId: childId, continuable: true, ran: false, run: null, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
264
292
  : { id: String(run.id), run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
293
+ // ⚠️ spawn 返回 → 登记 rec 必须**零 await**(既有纪律):continuable 子会话的 running→idle 事件
294
+ // 随时可能到达,事件通道必须马上能在活跃表里看到它——否则 idle 被 rec.ran 守卫挡下,结算干等硬超时。
265
295
  runsFor(sid)[t.id] = rec
266
296
  if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
267
297
  dispatchedEver[sid][String(rec.id)] = true
298
+ // ===== 落闸锁内重验(task-mv6c7cdm,仅 worker 角色;与续跑路径同一闸门 spawnGateOk)=====
299
+ // spawn 在途窗口内卡面可能已被 terminate 等路径翻走(占位清、回 pending)——此时本次派发基于
300
+ // 过期快照,必须弃权:settled 旗堵迟到结算 + 摘 rec + 停掉刚建的子会话(continuable=interrupt
301
+ // 留存口径与 terminate 一致;一次性=dispose),不落 run 留档——它在卡面从未存在过,卡面由
302
+ // terminate 那笔写重排(pending),下一轮 cycle 按现状重新决策。
303
+ // 为什么登记在闸门之前:闸门是持锁慢操作(锁内重读新鲜快照),若夹在「spawn 返回」与「登记」
304
+ // 之间会重新撕开事件可见性窗口——登记先行保住既有纪律,闸门否决时再逆向摘干净即可。
305
+ // verifier/hook 不验:verifier 的手动派发通道(dispatch-task)不设占位,验了会误杀正常手动派发;
306
+ // hook 的占位语义不同(state 机幂等)——二者都不在本次竞态实证范围内。
307
+ if (role === 'worker') {
308
+ var gateOk = await spawnGateOk(sid, t.id, '')
309
+ if (!gateOk) {
310
+ rec.settled = true // 先立旗:迟到的 idle 结算与超时臂一并放行空转
311
+ if (runsFor(sid)[t.id] === rec) delete runsFor(sid)[t.id]
312
+ try {
313
+ if (useContinuable) { if (typeof subagents.interrupt === 'function') subagents.interrupt(childId, { kind: 'ancestor', agent: parent }) }
314
+ else if (run && typeof run.dispose === 'function') await run.dispose()
315
+ } catch (_) {}
316
+ console.error('[task-board] spawn 落闸被锁内重验拒绝(卡面已变更,本次派发弃权)task ' + t.id + ' role ' + role)
317
+ return { vetoed: true }
318
+ }
319
+ }
268
320
  // 历史会话留档:每次派发都追加一条 {role,id,at,model},任务完成后仍可回看
269
321
  // 各阶段(含重试的第 1/2/3 次 Worker)会话——否则 claimedBy/verifierRun 只留最后一次。
270
322
  // **必须 await**(task-muwkhqf8):上报通道的收尾(settleReportedRun → closeRunHistory)要求在
@@ -396,10 +448,49 @@ export function createDispatch(ctx, state, deps) {
396
448
  // NOT_RESUMABLE / 邻接校验失败 / 任何错:一律回退 fresh spawn(本函数不抛,调用方按 rec 为 null 处理)。
397
449
  return { fallback: String(e) }
398
450
  }
451
+ // ⚠️ sendMessage 返回 → 登记 rec 必须**零 await**(既有纪律):冷复活子会话的 running→idle
452
+ // 随时可能到达,事件通道必须马上能在活跃表里看到它——否则 idle 被 rec.ran 守卫挡下,结算干等硬超时。
399
453
  var rec = { id: childId, childId: childId, continuable: true, resumed: true, ran: false, run: null, role: 'worker', taskId: t.id, startedAt: Date.now(), model: '', settled: false, baselineBytes: logSizeOf(childId) }
400
454
  runsFor(sid)[t.id] = rec
401
455
  if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
402
456
  dispatchedEver[sid][childId] = true
457
+ // ===== 落闸锁内重验(task-mv6c7cdm):sendMessage 冷复活是慢操作,在途期间卡面可能已被
458
+ // terminate 翻走(它看不到本次在途续跑——当时活跃表无 rec,interrupt 打不到、关账轮不到)。
459
+ // 闸门以锁内新鲜快照重验:本轮占位仍在 + 锁内重算 resumeTarget 仍指向同一 childId。
460
+ // 拒绝即弃权三件套:
461
+ // ① settled 旗 + 摘 rec——迟到的 idle 结算与本轮派发痕迹一并失效;
462
+ // ② interrupt 刚复活的子会话——否则它脱离观测空跑烧上下文(实证 f3f9a66f 空烧 64k tok);
463
+ // ③ 旧 run 留档立 noResume 旗——terminate 命中活跃 rec 时 closeRunHistory('terminated') 会让
464
+ // 续跑资格失效;这里留档早已落定关不动,补 noResume 达同一语义(用户 terminate 就是要换掉它),
465
+ // 下一轮 cycle 走 fresh spawn 新 Worker 而不是再次复活它。
466
+ // 不落的只有续跑留档——本次派发在卡面从未存在过。
467
+ // 为什么登记在闸门之前:闸门是持锁慢操作,夹在「sendMessage 返回」与「登记」之间会重新撕开
468
+ // 事件可见性窗口——登记先行保住既有纪律,闸门否决时再逆向摘干净(与 spawnOneShot 同款)。
469
+ var gateOk = await spawnGateOk(sid, t.id, childId)
470
+ if (!gateOk) {
471
+ rec.settled = true // 先立旗:迟到的 idle 结算与超时臂一并放行空转
472
+ if (runsFor(sid)[t.id] === rec) delete runsFor(sid)[t.id]
473
+ try { if (typeof subagents.interrupt === 'function') subagents.interrupt(childId, { kind: 'ancestor', agent: parent }) } catch (_) {}
474
+ try {
475
+ await mutateLocked(sid, function (d) {
476
+ var t2 = d.tasks.find(function (x) { return x.id === t.id })
477
+ if (!t2 || !Array.isArray(t2.runs)) return null
478
+ for (var ri = t2.runs.length - 1; ri >= 0; ri--) {
479
+ var r2 = t2.runs[ri]
480
+ if (!r2 || r2.role !== 'worker') continue
481
+ if (String(r2.id) !== String(childId)) break // 最新 worker 留档不是它 → 续跑资格早已易主,不动
482
+ if (r2.noResume === true) return null
483
+ if (r2.outcome !== 'timeout/error' && r2.outcome !== 'incomplete') return null // 仍 running 等形态归结算/终止路径管
484
+ r2.noResume = true
485
+ ah(t2, t2.status, t2.status, 'system', '派发落闸重验未通过(terminate 竞态):续跑弃权,旧子会话不再续跑')
486
+ return { ok: true }
487
+ }
488
+ return null
489
+ }, true)
490
+ } catch (_) {}
491
+ console.error('[task-board] 续跑落闸被锁内重验拒绝(terminate 竞态,已弃权并 interrupt 复活子会话)task ' + t.id + ' child ' + childId)
492
+ return { vetoed: true }
493
+ }
403
494
  armTimeouts(sid, rec, t, c)
404
495
  // runs[] 追加一条**续跑记录**(role:'worker', id:childId 与原 run 同 id=同一会话, resume:true):
405
496
  // 详情页可区分首派/续跑,且它成为续跑资格判定的最新依据。
@@ -828,7 +919,15 @@ export function createDispatch(ctx, state, deps) {
828
919
  // 失败原因落卡(截断 300):verifier 故障/输出无法判定的原因留在卡片上供排查
829
920
  t.lastError = String(errText || 'verifier 未给出有效结论(输出格式异常)').slice(0, 300)
830
921
  // 失败/空输出/无法判定:verifyRetries 计数,>=3 转人工验收(deliverable 已完成,是 verifier 故障不是任务故障)
831
- if (failed && rec.model) { badModels[modelKey(sid, rec.model)] = true; pushSysNote(sid, '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型') }
922
+ if (failed && rec.model) {
923
+ badModels[modelKey(sid, rec.model)] = true
924
+ // 熔断文案分态(task-mv73ym2k,实证 n-mv6ezj6vary4):错误串含 UNKNOWN_MODEL = 模型 id 非法
925
+ // (mistake 类:配置写错,模型无辜)——与「连续验收失败」(quality 类)区分,不归一堆。
926
+ var modelIllegal = /UNKNOWN_MODEL/i.test(String(errText || ''))
927
+ pushSysNote(sid, modelIllegal
928
+ ? '模型 ' + rec.model + ' 配置非法(不在可用清单),已熔断回退父级模型——请经 list-models 选合法 id 重配'
929
+ : '模型 ' + rec.model + ' 验收连续失败,已熔断回退父级模型')
930
+ }
832
931
  t.verifyRetries = (t.verifyRetries || 0) + 1
833
932
  if (t.verifyRetries >= 3) { t.escalation = { question: 'Verifier 连续 ' + t.verifyRetries + ' 次未能给出有效结论(' + (failed ? String(errText).slice(0, 150) : '输出格式异常') + ')。交付物已完成,请人工验收:看板详情页直接通过/驳回,或 task_verify 裁决。', at: new Date().toISOString(), by: 'system' }; ah(t, 'verifying', 'verifying', 'system', 'verifier 故障,转人工验收'); return { task: t, escalated: true } }
834
933
  ah(t, 'verifying', 'verifying', 'system', 'verifier 未给出有效结论,重新排队审查 (' + t.verifyRetries + '/3)')
@@ -1352,6 +1451,10 @@ export function createDispatch(ctx, state, deps) {
1352
1451
  try {
1353
1452
  var rec = null
1354
1453
  var resumeFallback = ''
1454
+ // 落闸弃权旗(task-mv6c7cdm):闸门拒绝 ≠ spawn 失败——卡面已被 terminate 等路径在 spawn 在途
1455
+ // 期间重排(占位已清、回 pending),本次派发基于过期快照被锁内重验拦下。弃权时绝不做失败回收
1456
+ // 与「启动失败」误报,卡面留给下一轮 cycle 按现状重新决策。
1457
+ var spawnVetoed = false
1355
1458
  // ===== 重派优先走「冷复活续跑」(卡2)=====
1356
1459
  // 只在 Worker 角色 + workerContinuable 开(缺省)时尝试:命中「上次 continuable run 结局=超时/失败」
1357
1460
  // 的任务时 sendMessage 唤醒原 child(不 spawn 新会话)。不适用(null)或失败(fallback)都落到下面的
@@ -1360,11 +1463,12 @@ export function createDispatch(ctx, state, deps) {
1360
1463
  var rr = await tryResumeWorker(sid, sp.t)
1361
1464
  if (rr && rr.rec) rec = rr.rec
1362
1465
  else if (rr && rr.fallback) resumeFallback = rr.fallback
1466
+ else if (rr && rr.vetoed) spawnVetoed = true
1363
1467
  }
1364
1468
  // 续跑失败才回退 fresh spawn:**先把说明写进任务消息再 spawn**——新 Worker 的首条 prompt 由
1365
1469
  // buildWorkerPrompt 从任务对象组装(含 messages),这样它自己就能看到「上一轮续跑为何没接上」,
1366
1470
  // 而不是只留在详情页给人事后翻账。返回的 t 是写盘后那份,直接用它 spawn。
1367
- if (!rec && resumeFallback) {
1471
+ if (!rec && resumeFallback && !spawnVetoed) {
1368
1472
  try {
1369
1473
  var noted = await mutateLocked(sid, function (d) {
1370
1474
  var t2 = d.tasks.find(function (x) { return x.id === sp.t.id })
@@ -1376,16 +1480,27 @@ export function createDispatch(ctx, state, deps) {
1376
1480
  if (noted && noted.t) sp.t = noted.t
1377
1481
  } catch (e) { console.error('[task-board] 续跑回退说明落卡失败 (task ' + sp.t.id + '):', String(e)) }
1378
1482
  }
1379
- if (!rec) rec = await spawnOneShot(sid, sp.t, sp.role)
1483
+ if (!rec && !spawnVetoed) {
1484
+ var sr = await spawnOneShot(sid, sp.t, sp.role)
1485
+ if (sr && sr.vetoed) spawnVetoed = true // 落闸弃权(fresh spawn 在途被翻卡)
1486
+ else rec = sr
1487
+ }
1380
1488
  if (rec) {
1381
1489
  // 成功派发计数(运行时健康自检):续跑命中与 fresh spawn 都算一次成功派发
1382
1490
  var __ph = poolHealthFor(sid); __ph.dispatchOk = (__ph.dispatchOk || 0) + 1; __ph.lastDispatchAt = Date.now()
1383
1491
  // claim 占位换成真实 run id;verifier/hook-post run 单独记(claimedBy 保留 worker 的,供详情页跳转会话)
1384
- await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (t) { if (sp.role === 'worker' && t.claimedBy === 'spawn-pending') t.claimedBy = String(rec.id); if ((sp.role === 'verifier' || sp.role === 'hook-post') && t.verifierRun === 'spawn-pending') { t.verifierRun = String(rec.id); t.verifierRunAt = new Date().toISOString() }; if (sp.role === 'hook-pre' && t.hooks && t.hooks.pre) t.hooks.pre.runId = String(rec.id) }; return t }, true)
1492
+ // 零改动不写盘(task-mv6c7cdm):落闸重验把 rec 登记提前到闸门之前后,续跑轮若撞上快速 idle
1493
+ // 结算(settle 已在闸门窗口内把卡翻走),本 swap 的占位守卫必然失败——此时写盘纯属在飞噪音
1494
+ // (实测:测试 cleanup 拆除临时目录后这笔迟到的 wt 以 ENOENT 刷屏),不变就返回 null。
1495
+ await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === sp.t.id }); if (!t) return null; var swapped = false; if (sp.role === 'worker' && t.claimedBy === 'spawn-pending') { t.claimedBy = String(rec.id); swapped = true }; if ((sp.role === 'verifier' || sp.role === 'hook-post') && t.verifierRun === 'spawn-pending') { t.verifierRun = String(rec.id); t.verifierRunAt = new Date().toISOString(); swapped = true }; if (sp.role === 'hook-pre' && t.hooks && t.hooks.pre && t.hooks.pre.runId !== String(rec.id)) { t.hooks.pre.runId = String(rec.id); swapped = true }; return swapped ? t : null }, true)
1385
1496
  // 派发即回执:spawn 真成功后才入队(占位阶段失败不通知);经 deps 注入,未注入静默跳过(老 host 兼容)
1386
1497
  // 回执开关(设置区「通知」):notifyDispatch=false → 派发回执整条跳过(spawn 照常,只闭嘴);
1387
1498
  // 闸门读本轮 poolCycle 已取的 cfg 快照 c(无额外读盘),老看板缺字段 → cfg 归一为 true。
1388
1499
  if (c.notifyDispatch !== false && typeof notifyDispatched === 'function') notifyDispatched(sid, sp.t, sp.role)
1500
+ } else if (spawnVetoed) {
1501
+ // 落闸弃权(task-mv6c7cdm):卡面已被 terminate 等路径重排,本轮 claim 作废——
1502
+ // 占位由翻卡那笔写清掉、被弃的子会话已由闸门 interrupt/dispose 停掉,这里刻意不再动卡面、
1503
+ // 不发「启动失败」误报(闸门函数内已各留一行 console 痕迹),等下一轮 cycle 重新决策。
1389
1504
  } else if (sp.role === 'worker') {
1390
1505
  // spawn 失败 → 回 pending
1391
1506
  await releaseSpawnPlaceholder(sid, sp, 'spawn 失败,回收重新排队')
@@ -1451,5 +1566,7 @@ export function createDispatch(ctx, state, deps) {
1451
1566
  // 在并行 spawn 下存在认领错包的潜伏 bug,一并消灭。
1452
1567
  }
1453
1568
 
1454
- return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory }
1569
+ // resumeTarget 一并透出(task-mv6c7cbo):rpc 侧 doIntervene 在无活跃 rec 时靠它细分
1570
+ // 「续跑窗口 / 真无 run」两态(纯读 t.runs 零 IO,同一函数两处复用,不另写第二套口径)。
1571
+ return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory, resumeTarget: resumeTarget }
1455
1572
  }