dsh-agent-board 1.7.2 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,9 +104,11 @@ dsh plugin --profile web remove dsh-agent-board
104
104
 
105
105
  - 仪表盘视图新增「Token 消耗」区:本看板累计**有效 / 缓存读 / 合计**三分量 + 输入 / 输出(缓存写非零时一并展示)拆分、按模型分布条形图、任务消耗 **Top 8**(标题可点击直达该任务详情,title 附「有效 N / 缓存读 M」拆分);大数字为**今日有效消耗**(本地日口径,无日账则显示 0,旁附「含缓存读共 X」小字对照,有效值不可知时以总量近似并标 `~`)、旁附「累计(本看板)」三分量小字,下方「近 7 天」迷你条形按日展示近 7 天**有效消耗**(今天高亮,title 补总量对照,7 天全为 0 时不渲染);进行中的卡片右上角显示本任务已累计消耗(`⛁ 数字`);区底固定一行口径 caption:**仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话**
106
106
  - 口径要点:**有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)**。实证本板累计 10.3M 里缓存读占 9.7M(94%)、有效消耗仅 605K——总量被缓存读撑高约 17 倍,因此大数字与「近 7 天」一律用有效值,缓存读单列可查(差额一眼可见),避免「仪表盘与真实消耗差太多」
107
+ - **主数字口径统一**(`task-muwq9u04`):**按模型分布行与任务消耗 Top 8 行的主数字一律为有效消耗**(含缓存读的合计进悬浮 title),与「今日」「近 7 天」同口径——不再出现「今日有效 3.7M / 单任务 21M」这类同区并排的矛盾数字(模型有效分摊由 host 的 `byModelEff` 给出;老 host 缺该字段或老卡无五分量留账时,主数字退化用合计并在 title 标 `~`)
108
+ - **统计范围接通 Token 区**(`task-muwc7hjd`):仪表盘顶部的「统计范围」(快捷:今天 / 近 7 天 / 近 30 天 / 全部 + 自定义起止)现在同时作用于 Token 区的**按模型分布 / 任务消耗 Top 8 / 累计三分量**——范围变化即用新范围重拉 `get-tasks`,由 host 按每个 run 的本地日落点(`t.runs[i].at` → `YYYY-MM-DD`,两端闭区间)过滤:范围外 run 不计入 total/byModel/top,`byDay` 同步裁到范围内;范围激活时 Token 区标题带「范围内: …」标记。**今日大数字与「近 7 天」柱子是自身固定口径,不随范围变化**(区底第二行 caption 与范围筛选浮层均写明)。无范围(两端皆空)时聚合结果与旧版逐字一致(parity);无 run 级留账(老卡 / runs 条目未落 usage)退化为按 `updatedAt` 本地日判定,无 `at` 的老 run 只在无范围时计入(宁可漏不错),run 未记模型名时按入选 run 占比摊任务级模型小计(合计与有效同法摊派,ΣbyModel 与入选总量、ΣbyModelEff 与入选有效各自对齐)。**数据源口径修正(`task-muwq9u04`)**:run 记录数组在**任务级 `t.runs[]`**(`t.usage.runs` 只是结算次数计数)——此前误把计数当数组读,范围过滤静默退化成 `updatedAt` 单日近似、Top 8 与无范围逐字相同;现已逐 run 精确裁切,且范围切换后客户端按 `usageSummary` 的 JSON 串比对补一次 notify(选范围即渲染,不等下一次任意刷新)
107
109
  - 数据来源:每次 Worker/Verifier run 结算时读该 run 的 v4 会话日志(`~/.dsh/sessions/*/<runId>/session.v4.jsonl.zstd`),把 `assistant/message` 事件的 `usage`(`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens` / `totalTokens`,字段形状以真实日志为准)按 zstd 帧逐帧累加到任务 `usage`(含按模型小计、`runs` 计数与 `byDay` 日账——本地日 `YYYY-MM-DD`,一次 run 整笔记在结算日;多轮重跑/驳回重做自动累加);**run 级留账**:本次用量同时原样写回 `t.runs` 对应条目的 `usage`(五分量俱全),因此按天 / 按模型 / 按阶段任何维度都能从 runs 精确重建,口径若要再调整不必回头猜
108
110
  - `byDay` 为**双指标**形态 `{ t, e }`(`t` = 总量含缓存读,`e` = 有效消耗不含缓存读;一次 run 不跨日拆分)。聚合端兼容老数据:值是裸 number 的历史日账按 `{ t: n, e: null }` 处理——总量照常保留(「近 7 天」不会整段消失),有效值不可知就置 `null` 并由 UI 标 `~` 近似,**绝不把总量冒充有效值**
109
- - `get-tasks` 再现算 board 级 `usageSummary`(总量 / 有效合计 / 按模型 / Top8(带有效与缓存读拆分)/ 日账 `byDay`,不落盘额外表;老任务无 `byDay` 时整笔近似归到 `updatedAt` 的本地日,无 `updatedAt` 则不计入任何日)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」
111
+ - `get-tasks` 再现算 board 级 `usageSummary`(总量 / 有效合计 / 按模型(合计 `byModel` + 有效分摊 `byModelEff`,两者同源累加)/ Top8(带有效与缓存读拆分)/ 日账 `byDay`,不落盘额外表;老任务无 `byDay` 时整笔近似归到 `updatedAt` 的本地日,无 `updatedAt` 则不计入任何日)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」;第二参 `range`(可选 `{ from, to }` 本地日)给定即只聚合该范围内的 run(口径见上条)
110
112
  - 统计口径边界:本区只统计**看板派发的 Worker/Verifier run**,**不含主窗口对话本身**(不越界统计,UI caption 已明示)
111
113
  - 「架构健康」区(架构自省 L1):`get-tasks` 顺带对**近 50 张卡**现算四信号(纯函数零存储:touches 声明热度 ≥8 次且占比 ≥40% / 带 touches 任务滞留中位数 >2 倍 / 任务**执行**时长 p90 >45min(claimedAt→resolvedAt 纯干活口径,不含排队)/ 同路径驳回 ≥2 次),命中才在仪表盘渲染提示条(⚠️/ℹ️ 两级,最多 3 条)——让运行数据主动提示"该优化架构了"(如某文件反复成为锁热点=该拆),信号只建议不裁判
112
114
  - 统计区耗时同口径拆分:「平均排队 / 平均执行」双行展示(平均验收单列不变)
@@ -133,7 +135,7 @@ draft → pending → in-progress → verifying → resolved → archived
133
135
  - **依赖调度**:`dependsOn` 声明依赖(DFS 环检测),依赖全部完成后才会被派发,串行链路自动编排
134
136
  - **管线分档**:`full`(执行+验证)/ `work`(只做不验)/ `direct`(不进池,主窗口直接处理),创建时按规则自动分类、可手动覆盖
135
137
  - **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
136
- - **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`/`resolved`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁持到归档**:验收通过后继续持锁(护住「验收后-提交前」的提交窗口期),归档(含批量归档)才真释放,`cancelled` 立即放锁(放弃语义=不再产出)——避免并行 Worker 改同一批文件互踩。手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
138
+ - **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁随工作态**:状态流转到已完成(`resolved`)即放锁,`cancelled`/归档同样不再持锁(归档回归纯收纳动作、不再是释放点)——锁只护「正在写」的阶段,「验收后-提交前」的窗口期由主窗口「回执到即提交」纪律 + 史诗 post-hook 承接,不用长持锁把整批串行化。避免并行 Worker 改同一批文件互踩;手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
137
139
  - **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
138
140
  - **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
139
141
  - **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档(僵尸态出清:`archive-task` 对「无活跃 run 的 in-progress」——典型如被 parentKick 推进后子任务已全部归档的史诗——直接放行,有活跃 run 的仍拒);**归档子任务仍计入史诗进度并在详情留档可见**——进度分子口径 `settled = resolved | cancelled | archived`、分母也含归档,归档一张子卡不会再让史诗进度从 `0/10` 退化成 `0/9`(进度只增不减),全归档的父卡也照常显示徽章;详情子任务清单不排归档行(灰化 + 行尾「已归档」徽章 + 沉底排序,点击仍可进子卡看留档)
@@ -150,11 +152,11 @@ draft → pending → in-progress → verifying → resolved → archived
150
152
 
151
153
  ### 一次性派发(v74 去池化)
152
154
 
153
- - 每个任务 spawn 一个**一次性子代理**(Worker/Verifier),上下文全量注入 prompt,做完即销毁——无常驻池、无池化状态残留
154
- - **预研上下文注入(contextFiles/contextNotes)**:主窗口调研时读过的文件与笔记,由 host 在派发时读盘取最新内容,经「上下文注入」区块提供给 Worker/Verifier(不混进 user prompt);预算口径单文件 8KB、总包 40KB;UI 侧调研门禁——full/work 且声明了 touches 却未附调研的卡片亮「⚠️ 无调研」徽章,详情页「调研注入」区列 files 清单 + notes 字数(无则明示)
155
- - **锚点行段**:`contextFiles` 支持 `path:L2350-L2420` / `path:L2350` 行段语法(只认尾部 `:L<行号>`,兼容 Windows 盘符),只注入该段(段长上限 400 行,超出截断并标注);锚点无效(越界/写法错)自动回退头部注入并标注「锚点无效,已回退头部」
156
- - **截断结构索引**:头部注入被预算截断时,标注升级为「截断:共 N 行,已注入 1–M 行」,并附结构索引块(JS/TS 顶层函数/类/箭头赋值、Markdown 标题及行号,上限 40 条)——Worker 照索引用锚点语法补读目标段即可,不用全文盘点
157
- - **派发调研门禁(warning 族,软提示不阻断)**:`task_create`/`create-task` 响应附 `warning` 字段——①描述为空「Worker 只能凭标题猜需求」②full/work + touches 非空而未附调研上下文 ③touches 含整树 glob 建议精确到文件级(可多条合并);GUI 表单内黄色展示不关窗。epic 发布(publish)时自动轻量预检全部子任务注入情况,缺材料则 pushSysNote 汇总提醒主窗口(全有不打扰);派发时读包失败落任务「最近失败」行,不再静默
155
+ - 每个任务 spawn 一个**一次性子代理**(Worker/Verifier),上下文全量注入 prompt,做完即销毁——无常驻池、无池化状态残留(Worker 的可续跑形态见上一节「可续跑 Worker」,Verifier 与 hook run 恒为一次性)
156
+ - **预研上下文注入(contextFiles/contextNotes,瘦身分离形态)**:主窗口调研时读过的文件与笔记随子代理的**首条 prompt 一次性注入**——调研笔记全文(notes,≤8000 字符)+ **文件清单**(每行「`路径:L起-L止` — 一句用途」);**文件内容本体不进 prompt**,由子代理用 `read` 工具按行号范围按需自取(执行时盘面更新鲜;旧形态「host 读盘取正文注入」既受单文件 8KB/总包 40KB 截断,又随 runtime 快照每轮刷新重发——自治 run 实测 6×48.8K 字符≈白烧 75–100K token);UI 侧调研门禁——full/work 且声明了 touches 却未附调研的卡片亮「⚠️ 无调研」徽章,详情页「调研注入」区列 files 清单 + notes 字数(无则明示)
157
+ - **锚点行段与用途**:`contextFiles` 条目写法「`path:L2350-L2420` / `path:L2350`」+ 可选「` — 一句用途`」(em dash 两侧空格分隔;缺省只给路径行号)——锚点只认尾部 `:L<行号>`(兼容 Windows 盘符),清单里原样带上行号供子代理直接按行段 read;锚点写错(`:L0` / `:L5-L2`)自动剥掉,不误导子代理去读空段
158
+ - **按需自取代替 host 预切段**:派发侧零读盘(不再切行段、不再附结构索引块)——子代理自己 `read(path, offset, limit)` 取需要的那段,清单里给出的行号就是起点;`task_preview_context` / `preview-context` 返回的也是这份瘦身清单(不是文件正文)
159
+ - **派发调研门禁(warning 族,软提示不阻断)**:`task_create`/`create-task` 响应附 `warning` 字段——①描述为空「Worker 只能凭标题猜需求」②full/work + touches 非空而未附调研上下文 ③touches 含整树 glob 建议精确到文件级(可多条合并);GUI 表单内黄色展示不关窗。epic 发布(publish)时自动轻量预检全部子任务注入情况,缺材料则 pushSysNote 汇总提醒主窗口(全有不打扰);派发时清单组装失败落任务「最近失败」行,不再静默
158
160
  - **Worker/Verifier 均可配置异构模型**(⚙️ 弹出层下拉选择,空 = 继承父级),避免同源盲点;模型故障自动熔断回退父级模型
159
161
  - 孤儿回收:子代理 run 结束/丢失超 2 分钟 → 任务自动回待办重派
160
162
  - 看门狗:运行超时且事件流停滞 → 标记"疑似卡死"(不自动杀,裁决权交主窗口/用户)
@@ -163,6 +165,21 @@ draft → pending → in-progress → verifying → resolved → archived
163
165
  - 手动派发:详情页「派发 / 派发验收」按钮可随时手动触发单任务派发(auto 模式补派、manual 模式主通道)
164
166
  - 会话隔离:看板按会话分桶,多会话互不干扰
165
167
 
168
+ ### 可续跑 Worker(continuable,卡1~卡3 已落地,默认开)
169
+
170
+ **一句话**:Worker 从「一次性 run」变成**持久子会话**——超时不再丢现场,重派时原会话**冷复活**接着干;Verifier 与 hook run 仍是一次性。
171
+
172
+ - **continuable 化**:Worker 走 `subagents.startContinuable`(rec 持 `childId`),turn 结束改由 host 事件 `agent/status` 的 `running→idle` 观测(一次性路径本就用 `run.result` 结算,未受影响);`claimedBy` / `t.runs[].id` / 详情页会话跳转的 id 语义不变(仍是子会话 id)
173
+ - **硬超时 interrupt 留存(不销毁)**:失败/硬超时结算时只对子会话发取消信号打断当前 turn——Activation、未认领收件箱、已发布后代全部保留,子会话 idle 后仍可被唤醒;`interrupt` 失败(会话已死/无权限)绝不阻断结算:任务照常回待办,只给该 run 落 `noResume` 标记(重派直接起新 Worker,不空唤醒)
174
+ - **重派冷复活续跑**:命中「上次 continuable Worker 结局=超时/失败」的待办卡时,不 spawn 新会话,改 `subagents.sendMessage(活父 Agent, childId, 断点续跑指令)`——子会话带着上一轮全部上下文复活,先盘点工作树再从断点继续;`t.runs[]` 追加一条 `resume: true` 记录(详情页历史会话按钮带 **↻** 标记),续跑同样计入三连败计数;续跑不可用(`NOT_RESUMABLE` 等)→ 回落全新 Worker,并把原因写进任务消息随首条 prompt 注入
175
+ - **续跑基线(不拿旧文本冒充交付物)**:续跑轮结算只认**基线字节之后**新写的助手文本——一个字没产出就走「空文本按失败」重排,绝不把上一轮(被中断那次)的残留文本当成本轮交付物推进验收
176
+ - **重启 reconcile(找回活跃续跑 Worker)**:host 重启会清空内存里的活跃 run 表,但持久子会话还活着。首轮派发周期对「进行中且无活跃 run」的卡查一次 `listChildren(root)`:仍在列 → 重建 rec 观测(监听器本就在)并重挂两级超时臂、基线取当前日志字节数;不在列 → 视为会话已死,走硬超时等价物(回待办重排 + 留一行流转记录),不占 Worker 并发位
177
+ - **usage 增量计账(按 seq 水位线)**:注意「一个持久子会话被结算多次」是新形态——整份日志全量累加会把前几轮的 token 反复记账(实测同一 childId 结算两次=双倍)。现按 v4 日志事件自带的 `seq` 记水位线(落在 `t.runs[].usageSeq`):每次结算只累加水位线之后的 `assistant/message` 增量,本轮没新增量就一行都不记;one-shot 路径(每 run 独立日志)行为逐字不变
178
+ - **开关 `workerContinuable`(默认开)**:关掉即逐字回退旧的一次性路径(零 `startContinuable`/零 `sendMessage`/零 `interrupt`,结算仍走 `run.result`+`dispose`);Verifier 与 hooks(`hook-pre`/`hook-post`)**保持一次性**,不受该开关影响
179
+ - **结算双通道 + 池韧性(2026-10-06 事故修复)**:continuable 结算有事件通道(`agent/status` 的 running→idle,身份取 `agent.session.id`)与上报通道(`board_report` 落定即收尾)两条入口,任一到达即关账(结局/usage/超时臂三件套,幂等);派发周期自带**幽灵活跃表项 GC**(卡面证据核对回收残留 rec,防残留把派发容量顶到 0 拖死全池)+ 整轮 try/catch 与逐卡隔离(单点异常只作废该卡该轮)+ 去抖 latch 时间戳兜底复位
180
+ - **续跑指令优先级**:断点续跑指令会带上卡上 messages 原文(仲裁/干预/驳回理由),并声明**最新裁决/干预优先于历史原始契约**(冲突以最新为准)——冷复活子会话的历史里没有仲裁答案,不带原文它无从知晓
181
+ - **高优干预实时送达 continuable Worker**:`task_intervene` 对 continuable rec 走宿主投递通道(保留插件 source,下一个 step 边界消费),会话不可用降级 `sendMessage` 冷复活投递,再不行回退「记录注入随重派送达」并在 history 注明
182
+
166
183
  ### 工作模式(三档)
167
184
 
168
185
  看板上一个选择器切换三档工作模式(RPC 单入口 `set-work-mode`,`mode` = `list` / `auto` / `team`):
@@ -177,7 +194,7 @@ draft → pending → in-progress → verifying → resolved → archived
177
194
 
178
195
  - **歧义裁决**:Worker 遇歧义不猜测,一律上报;裁决后新 Worker 携带答案接手(Team 托管档附带 system prompt 派发引导 + 默认草稿护栏)
179
196
  - **Verifier 验收**:`acceptance` 硬性验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑;跨档一致
180
- - **touches 排他**:`touches` 文件级排他锁在活动任务间生效,冲突任务跳过本轮派发,**锁持到归档**(`in-progress`/`verifying`/`resolved` 持有,归档释放,`cancelled` 立即释放);跨档一致
197
+ - **touches 排他**:`touches` 文件级排他锁在活动任务间生效,冲突任务跳过本轮派发,**锁随工作态**(`in-progress`/`verifying` 持有;状态流转到 `resolved` 即释放,`cancelled`/归档同样释放——归档不再承担解锁职责);跨档一致
181
198
  - 孤儿回收、看门狗、级联归档、会话隔离同样三档一致
182
199
 
183
200
  Team 托管档独有(调度员体验):
@@ -228,11 +245,11 @@ v1.6.0 起 host 端从单体 index.mjs(1487 行)拆为薄壳 + 7 个领域
228
245
  | 模块 | 域 | 内容 |
229
246
  |---|---|---|
230
247
  | `policy.mjs` | 策略层 | 粒度治理软闸门 + 学习飞轮候选教训(纯函数零状态) |
231
- | `usage.mjs` | 统计 | v4 会话日志定位 / zstd 分帧 / token usage 聚合(纯函数;有效消耗 `effectiveTokens` + `byDay` 双指标 `{t,e}` 聚合,兼容老 number 日账) |
248
+ | `usage.mjs` | 统计 | v4 会话日志定位 / zstd 分帧 / token usage 聚合(纯函数;有效消耗 `effectiveTokens` + `byModelEff` 模型有效分摊 + `byDay` 双指标 `{t,e}` 聚合,兼容老 number 日账;统计范围按任务级 `t.runs[].at` 逐 run 裁切;`sinceSeq` 水位线增量结算——同一持久子会话多次结算不重复计账) |
232
249
  | `session.mjs` | 会话 | root 解析缓存 / 会话 id 归一 / workMode 派生 / runsFor |
233
250
  | `store.mjs` | 持久化 | boardPath / rt / wt 原子落盘 / 跨重启继承 / fileLocks 串行化 / mutateLocked |
234
251
  | `notify.mjs` | 通知 | makeMsg / 歧义 25s 去抖 / 回执聚合 + 空闲门控 / 投递前过滤 |
235
- | `dispatch.mjs` | 派发引擎 | poolCycle / spawnOneShot / settleRun / 两级超时 / 孤儿回收 |
252
+ | `dispatch.mjs` | 派发引擎 | poolCycle / spawnOneShot / settleRun / 两级超时 / 孤儿回收 / 可续跑 Worker(continuable + 重启 reconcile + usage 水位线) |
236
253
  | `rpc.mjs` | 接口层 | RPC 路由 + 13 个 Agent 工具注册 |
237
254
 
238
255
  (store→dispatch 的循环依赖由 `deps.poolCycle` 晚绑定解开;index.mjs 对外 re-export 契约不变。)
package/index.mjs CHANGED
@@ -35,11 +35,12 @@ export function apply(ctx) {
35
35
  epicSplitCache: {}, // 史诗拆分总开关缓存(rt()/set-board-config 同步,Team 引导段读取)
36
36
  fileLocks: {}, // 每会话一条 promise 链,串行化所有 读-改-写
37
37
  cyclePending: {}, // kickCycle 50ms 去抖
38
- activeRuns: {}, // sid -> { taskId: { run, role, taskId, startedAt, model } }
38
+ activeRuns: {}, // sid -> { taskId: { id, run, role, taskId, startedAt, model, settled, continuable?, childId?, ran? } }
39
+ // id = 该次 run 的子会话 id(一次性=run.id,continuable=childId,语义统一);
40
+ // continuable Worker 无 run(持久子会话),turn 结算靠 agent/status 事件见 dispatch.mjs
39
41
  dispatchedEver: {}, // sid -> { runId: true }(回执判定:区分派发执行 vs 主窗口手动)
42
+ reconcileDone: {}, // sid -> true(可续跑 Worker 重启 reconcile 的 per-host 一次性标记,见 dispatch.mjs)
40
43
  badModels: {}, // 模型熔断坏名单(sid|model → true)
41
- packByChild: {}, // 预研文件注入缓存(按子代理会话 id)
42
- pendingPacks: [], // 首轮竞速认领队列(start() 返回前的首次 prompt 组装)
43
44
  escNotifyTimers: {}, // 歧义通知 25s 去抖(同任务新调度顶替旧调度)
44
45
  receiptBuf: {}, // 回执 45s/满 5 条聚合窗口
45
46
  receiptedKeys: {}, // 回执幂等去重(完成事件指纹,1h TTL)
@@ -54,7 +55,8 @@ export function apply(ctx) {
54
55
  var notify = createNotify(ctx, state, { rt: store.rt, rootForSession: session.rootForSession, withTimeout: session.withTimeout, isDispatched: session.isDispatched })
55
56
  var dispatch = createDispatch(ctx, state, {
56
57
  rt: store.rt, wt: store.wt, mutateLocked: store.mutateLocked, kickCycle: store.kickCycle,
57
- rootForSession: session.rootForSession, sessionCwd: session.sessionCwd,
58
+ rootForSession: session.rootForSession,
59
+ // sessionCwd 不再注入 dispatch(预研清单瘦身后派发侧不读盘);rpc 侧仍需要(epic 预检路径存在性)
58
60
  withTimeout: session.withTimeout, runsFor: session.runsFor, feedbackOn: session.feedbackOn, epicSplitOn: session.epicSplitOn,
59
61
  pushSysNote: notify.pushSysNote, maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
60
62
  notifyDispatched: notify.notifyDispatched,
@@ -71,6 +73,10 @@ export function apply(ctx) {
71
73
  maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
72
74
  pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
73
75
  spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
76
+ // 上报通道结算入口(task-muwkhqf8):board_report/board_verdict 把任务推进到落定态时,
77
+ // 由 rpc 侧顺手对「该任务当前的 continuable Worker rec」补做收尾三件套
78
+ // (关 run 结局 / usage 落账 / 摘超时臂)——治「工具上报完成后 idle 事件没到 → run 永远 running」。
79
+ reportRunSettled: dispatch.settleReportedRun,
74
80
  hasActiveRun: hasActiveRun,
75
81
  })
76
82
 
@@ -131,7 +131,14 @@
131
131
  var _R = React; var useState = _R.useState
132
132
  var _a = useState(state.rfOpen || false), open = _a[0], setOpen = _a[1]
133
133
  var rg = activeRange()
134
- function setRange(from, to) { state.dateRange = { from: from, to: to }; notify() }
134
+ function setRange(from, to) {
135
+ state.dateRange = { from: from, to: to }
136
+ // 报告/总览是本地现算,notify 即可;Token 区的模型分布/Top8/累计要按范围重算,
137
+ // 而过滤在 host(run 级数据只在 host)→ 必须重拉一次 get-tasks(fetchTasks 会带上新范围)。
138
+ // fetchTasks 在 kernel 域定义,同处 apply 函数体 → 函数声明提升,此处可用。
139
+ if (typeof fetchTasks === 'function') fetchTasks()
140
+ notify()
141
+ }
135
142
  function preset(days) {
136
143
  if (days === 0) { setRange('', ''); return }
137
144
  var to = new Date(); var from = new Date(Date.now() - (days - 1) * 86400000)
@@ -159,8 +166,8 @@
159
166
  React.createElement('input', { type: 'date', value: rg.from, onChange: function (e) { setRange(e.target.value, rg.to) }, style: dateInput })),
160
167
  React.createElement('label', { style: { fontSize: 10, color: C.text2, display: 'inline-flex', alignItems: 'center', gap: 4 } }, '截至',
161
168
  React.createElement('input', { type: 'date', value: rg.to, onChange: function (e) { setRange(rg.from, e.target.value) }, style: dateInput })),
162
- React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '作用于报告与全局总览')),
163
- React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 5 } }, '报告统计「时间范围内有活动」的任务;总览只显示范围内有活跃的会话。')) : null)
169
+ React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '作用于报告、全局总览与 Token 区(模型分布/Top8/累计)')),
170
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 5 } }, '报告统计「时间范围内有活动」的任务;总览只显示范围内有活跃的会话;Token 区按 run 的本地日落点过滤(今日与近 7 天为固定口径,不受范围影响)。')) : null)
164
171
  }
165
172
 
166
173
  function GlobalBoards() {
@@ -268,12 +275,29 @@
268
275
  function TokenUsage(props) {
269
276
  var u = props.usage
270
277
  var box = { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 }
271
- var head = React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('bar-chart-3', 11), 'Token 消耗')
278
+ // 范围激活标记:Token 区的模型分布/Top8/累计已按「统计范围」过滤(host 侧重算),
279
+ // 不加标记的话用户没法判断看到的数字是全量还是范围内——标记与 RangeFilter 同源(activeRange)。
280
+ var tg = activeRange()
281
+ var tgOn = !!(tg.from || tg.to)
282
+ var head = React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('bar-chart-3', 11), 'Token 消耗',
283
+ tgOn ? React.createElement('span', { style: { fontSize: 9, fontWeight: 400, color: C.brand, border: '1px solid ' + C.brand, borderRadius: 8, padding: '0 6px' }, title: '模型分布 / 任务消耗 Top 8 / 累计已按统计范围过滤:' + rangeLabel() }, '范围内: ' + rangeLabel()) : null)
272
284
  if (!u || !u.total) return React.createElement('div', { style: box }, head, React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据(Worker/Verifier 会话日志里还没有 usage 记录)'))
273
- var models = Object.keys(u.byModel || {}).map(function (m) { return { model: m, total: u.byModel[m] || 0 } }).sort(function (a, b) { return b.total - a.total })
274
- var maxM = models.length ? (models[0].total || 1) : 1
275
- var top = u.topTasks || []
276
- var maxT = top.length ? (top[0].total || 1) : 1
285
+ // ===== 主数字口径统一(task-muwq9u04):模型分布行与 Top8 行的主数字一律显示**有效消耗**(e),
286
+ // 含缓存读的合计(t)退到 title 悬浮;否则同一块里「今日有效 3.7M」与「单任务 21M」并排自相矛盾
287
+ // (用户就是这么判成 bug 的)。数据源:host 的 byModelEff(模型有效分摊)与 topTasks[].effective;
288
+ // 老 host 缺 byModelEff / 老卡无五分量留账 → 退化用合计值并在 title 标 ~(口径不伪造)。
289
+ // 排序按**显示口径**(有效)降序:条形长度与行序一致,否则首行不是最长的条。
290
+ var models = Object.keys(u.byModel || {}).map(function (m) {
291
+ var tt = u.byModel[m] || 0
292
+ var ee = (u.byModelEff && typeof u.byModelEff[m] === 'number') ? u.byModelEff[m] : null
293
+ return { model: m, total: tt, eff: (ee === null ? tt : ee), approx: ee === null }
294
+ }).sort(function (a, b) { return b.eff - a.eff })
295
+ var maxM = models.length ? (models[0].eff || 1) : 1
296
+ var top = (u.topTasks || []).map(function (x) {
297
+ var ee = (typeof x.effective === 'number') ? x.effective : null
298
+ return { id: x.id, title: x.title, total: x.total, cacheRead: x.cacheRead, runs: x.runs, eff: (ee === null ? x.total : ee), approx: ee === null }
299
+ }).sort(function (a, b) { return b.eff - a.eff })
300
+ var maxT = top.length ? (top[0].eff || 1) : 1
277
301
  // 日账:今日数字取本地日 key,没有日账(byDay 缺字段/老 host)时退化为 0,不炸也不误报。
278
302
  // 读侧兼容两种单元形态:老 number(只有总量,有效值不可知)→ { t: n, e: null }。
279
303
  var byDay = (u.byDay && typeof u.byDay === 'object') ? u.byDay : {}
@@ -319,18 +343,22 @@
319
343
  }))) : null,
320
344
  React.createElement('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
321
345
  React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
322
- React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布'),
346
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布(有效消耗)'),
323
347
  models.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : models.map(function (m) {
324
- return React.createElement(UsageRow, { key: m.model, label: m.model, value: m.total, max: maxM, color: C.brand, title: m.model + ':' + String(m.total) + ' tokens' })
348
+ return React.createElement(UsageRow, { key: m.model, label: m.model, value: m.eff, max: maxM, color: C.brand, title: m.model + ':有效 ' + (m.approx ? '~' : '') + String(m.eff) + ' tokens(不含缓存读)' + (m.approx ? '——本模型无有效分量留账,以合计近似' : '') + ' · 含缓存读合计 ' + String(m.total) + ' tokens' })
325
349
  })),
326
350
  React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
327
- React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8'),
351
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8(有效消耗)'),
328
352
  top.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : top.map(function (x) {
329
353
  var t = getTask(x.id)
330
- return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.total, max: maxT, color: C.ok, labelColor: t ? C.brand : C.text2, title: x.title + '(合计 ' + String(x.total) + ' tokens · 有效 ' + String((typeof x.effective === 'number') ? x.effective : (x.total - (x.cacheRead || 0))) + ' / 缓存读 ' + String(x.cacheRead || 0) + ' · ' + (x.runs || 0) + ' 次 run)' + (t ? '——点击查看详情' : ''), onClick: t ? function () { state.detailId = x.id; notify() } : undefined })
354
+ return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.eff, max: maxT, color: C.ok, labelColor: t ? C.brand : C.text2, title: x.title + '(有效 ' + (x.approx ? '~' : '') + String(x.eff) + ' tokens(不含缓存读) · 含缓存读合计 ' + String(x.total) + ' · 其中缓存读 ' + String(x.cacheRead || 0) + ' · ' + (x.runs || 0) + ' 次 run)' + (x.approx ? '(老卡无五分量留账,有效值以合计近似)' : '') + (t ? '——点击查看详情' : ''), onClick: t ? function () { state.detailId = x.id; notify() } : undefined })
331
355
  }))),
332
356
  // 口径边界:本区只统计看板派发的 Worker/Verifier run,主窗口对话自身不越界纳入
333
- React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话;大数字与「近 7 天」为有效消耗(输入+输出+缓存写,不含缓存读),缓存读单列'))
357
+ // 范围说明(task-muwc7hjd):用户常把 RangeFilter 当成「整页过滤」,但今日大数字与近 7 天柱子
358
+ // 是自身固定口径(今日=本地今天、近 7 天=最近 7 个本地日)——不随范围变,必须在文案里讲清,
359
+ // 否则「选了范围数字没变」看起来像 bug。模型分布/Top8/累计才是范围生效的三处。
360
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话;主数字(模型分布 / Top 8)与「今日」「近 7 天」均为有效消耗口径(输入+输出+缓存写,不含缓存读),含缓存读的合计在悬浮 title 里单列对照'),
361
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2, lineHeight: 1.5 } }, '统计范围作用于按模型分布 / 任务消耗 Top 8 / 累计三分量(按 run 的本地日落点过滤);今日与「近 7 天」为固定口径,不随范围变化。'))
334
362
  }
335
363
 
336
364
  // ===== 架构健康提示区(架构自省 L1 · 数据源:state.healthHints)=====
@@ -466,6 +494,19 @@
466
494
  var _a = useState(false), open = _a[0], setOpen = _a[1]
467
495
  var _p = useState(null), pos = _p[0], setPos = _p[1]
468
496
  var ref = useRef(null); var btnRef = useRef(null)
497
+ // 开关点击即时反馈(反馈:勾选几秒才同步,task-muw5uudk):
498
+ // checked 是受控值(读 Props→state,由 3s 轮询通知才更新),此前 onChange 只发 rpc →
499
+ // 点下去要等 set-board-config + fetchTasks 双往返(安静板卡上 tasksHash 不变还会被渲染节约吃掉)才翻面。
500
+ // 口径:**先写 state + notify(点击瞬时翻面)→ 再 rpc('set-board-config') → 失败回滚 state + notify
501
+ // 并复用错误条 reportReadErr(沿用「读路径错误防线」:绝不让开关停在未落盘的值上)**。
502
+ // 成功路径的权威纠偏在 kernel.fetchTasks(配置变化检测),此处不重复 fetchTasks:3s 轮询自会带回真值。
503
+ function cfgRollback(key, prev) { state[key] = prev; notify() }
504
+ function setCfg(key, next) {
505
+ var prev = !!state[key] // 乐观更新前的值,失败回滚用
506
+ state[key] = next === true // 归一成布尔:默认开口径由服务端权威值(fetchTasks)纠偏
507
+ notify() // 立即翻面,不等 rpc
508
+ rpc('set-board-config', { key: key, value: next === true }).catch(function (e) { cfgRollback(key, prev); reportReadErr('设置保存失败:' + readErrText(e)) })
509
+ }
469
510
  useEffect(function () { if (!open) return; function onDown(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }; document.addEventListener('mousedown', onDown); return function () { document.removeEventListener('mousedown', onDown) } }, [open])
470
511
  function toggle() {
471
512
  // position:fixed + 视口坐标:面板容器是 overflow:hidden + maxHeight:60vh,
@@ -494,16 +535,16 @@
494
535
  // 学习飞轮 v1 总开关:关掉后不生成候选教训、prompt 不提软召回、详情页不渲染「沉淀」按钮
495
536
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '学习反馈'),
496
537
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
497
- React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) { rpc('set-board-config', { key: 'feedbackEnabled', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
538
+ React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) { setCfg('feedbackEnabled', e.target.checked) } }),
498
539
  React.createElement('span', null, '候选教训(Verifier 驳回/仲裁结论自动生成候选,Worker prompt 提示先检索历史教训)')),
499
540
  // 回执开关(用户要求「回执可以做一个开关,放到设置里」):派发/完成两类回执各自可关,缺省都开。
500
541
  // 只影响回执播报,不影响派发与状态机;歧义裁决通知不在此闸门内(见下行说明文案)。
501
542
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '通知'),
502
543
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
503
- React.createElement('input', { type: 'checkbox', checked: props.notifyDispatch !== false, onChange: function (e) { rpc('set-board-config', { key: 'notifyDispatch', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
544
+ React.createElement('input', { type: 'checkbox', checked: props.notifyDispatch !== false, onChange: function (e) { setCfg('notifyDispatch', e.target.checked) } }),
504
545
  React.createElement('span', null, '⚡ 派发回执(任务被 Worker/Verifier 领走时播报)')),
505
546
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260, marginTop: 3 } },
506
- React.createElement('input', { type: 'checkbox', checked: props.notifyDone !== false, onChange: function (e) { rpc('set-board-config', { key: 'notifyDone', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
547
+ React.createElement('input', { type: 'checkbox', checked: props.notifyDone !== false, onChange: function (e) { setCfg('notifyDone', e.target.checked) } }),
507
548
  React.createElement('span', null, '✅ 完成回执(任务完成或阻塞时聚合播报)')),
508
549
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '歧义裁决通知不受这两个开关影响(任务等人裁决必须提醒)'),
509
550
  // 史诗拆分总开关(板级 epicSplit,缺省 true):**只关引导,不禁机制**——关掉后 Team 提示词不再
@@ -511,7 +552,7 @@
511
552
  // 子卡、史诗自动收口/hooks 状态机照常(用户/主窗口明确要拆时不受阻)。勾选态缺字段=开,与 host 同口径。
512
553
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '功能'),
513
554
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
514
- React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { rpc('set-board-config', { key: 'epicSplit', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
555
+ React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { setCfg('epicSplit', e.target.checked) } }),
515
556
  React.createElement('span', null, '🧩 史诗拆分:大任务引导拆为 epic + 子任务')),
516
557
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作')) : null)
517
558
  }
@@ -284,10 +284,28 @@
284
284
  }
285
285
  }
286
286
 
287
+ // ===== 设置开关权威纠偏(反馈:勾选几秒才同步,task-muw5uudk)=====
288
+ // 开关字段(学习反馈/派发回执/完成回执/史诗拆分)在 get-tasks 里**无条件**照常赋值,但取值口径是
289
+ // 「缺字段/脏值=开,只有显式 false 才关」,与 host core.cfg 同口径——单点定义,避免纠偏把脏值当真值。
290
+ function cfgKnobOf(src, key) { return !(src && src[key] === false) }
291
+ // 变更检测:与 notify 同类的轻量浅比较(只看这几个布尔开关;settings 无嵌套对象)。
292
+ function cfgKnobsChanged(a, b) { return a.feedbackEnabled !== b.feedbackEnabled || a.notifyDispatch !== b.notifyDispatch || a.notifyDone !== b.notifyDone || a.epicSplit !== b.epicSplit }
293
+
287
294
  function fetchTasks() {
288
295
  if (!state.sessionId) return
289
296
  var epoch = reqEpoch
290
- rpc('get-tasks').then(function (d) {
297
+ // ===== 统计范围随轮询带给 host(Token 区的模型分布/Top8/累计要按范围重算)=====
298
+ // 为什么过滤在 host 做:run 级数据(runs[i].usage + runs[i].at)只在 host,客户端只拿聚合,
299
+ // 想按范围裁只能重拉一次 host 聚合——所以范围变化时由 RangeFilter 的 setRange 触发本函数(见 dashboard.js)。
300
+ // 空范围(两端皆空)不传 field:host 收到 undefined 即全量聚合,与老 host 的调用形态完全一致;
301
+ // 绝不用空字符串占位(host 侧 `from`/`to` 空串虽也判为无范围,但少传一个字段就少一处口径分叉)。
302
+ var rgSend = activeRange()
303
+ var rpcArgs = (rgSend.from || rgSend.to) ? { range: rgSend } : undefined
304
+ // 范围守卫:范围已在本轮响应回来前被改掉 → 这份响应属于旧范围,丢弃 usageSummary 赋值
305
+ // (否则旧范围的旧聚合会覆盖新范围的新聚合,用户看到「切了范围数字没变」直到下一次轮询)。
306
+ // 注意 tasksHash 短路只管 tasks:usageSummary 照常赋值(现状已对,保持)。
307
+ var rgKey = rgSend.from + '~' + rgSend.to
308
+ rpc('get-tasks', rpcArgs).then(function (d) {
291
309
  if (epoch !== reqEpoch) return // 会话已切换,丢弃过期响应
292
310
  if (d && d.error) { reportReadErr('看板数据刷新失败:' + d.error); return } // error 分支:保留旧 tasks/设置,绝不当空板渲染
293
311
  clearReadErr()
@@ -298,6 +316,11 @@
298
316
  // 老 host 不返回 tasksHash → 恒视为变化,行为与旧版完全一致。
299
317
  var newHash = (d && d.tasksHash) || ''
300
318
  var tasksChanged = !newHash || newHash !== state.tasksHash
319
+ // 设置开关的权威纠偏(task-muw5uudk):开关不走 tasksHash,hash 不变分支里照常赋值也没有 notify
320
+ // → 勾选框必须等下一次任意 notify 才翻面(安静板卡数秒)。这里在赋值前快照、赋值后比对,
321
+ // 有变化就补一次 notify:乐观更新(PoolCfgPopover)已让点击瞬时翻面,本兜底是服务端权威值纠偏
322
+ // (乐观值与服务端不一致时以服务端为准,失败回滚亦由此收敛)。
323
+ var cfgKnobs = { feedbackEnabled: state.feedbackEnabled, notifyDispatch: state.notifyDispatch, notifyDone: state.notifyDone, epicSplit: state.epicSplit }
301
324
  if (tasksChanged) {
302
325
  state.tasksHash = newHash
303
326
  state.tasks = (d && d.tasks) || []
@@ -316,7 +339,18 @@
316
339
  state.softTimeoutMin = (d && d.softTimeoutMin) || 30
317
340
  state.hardTimeoutMin = (d && d.hardTimeoutMin) || 120
318
341
  state.poolStatus = (d && d.poolStatus) || null
319
- state.usageSummary = (d && d.usageSummary) || null // token 消耗聚合(board 级,host 端现算)
342
+ // token 消耗聚合(board 级,host 端现算):范围守卫——响应回来时范围若已变,保留旧值不覆盖
343
+ // (新范围的那次请求会带着新聚合回来;老 host 不返回该字段 → null,零渲染)
344
+ // ===== usageSummary 变化检测(task-muwq9u04:选范围不重渲染)=====
345
+ // 病根:范围切换只让 state.usageSummary 换对象,tasksHash 与四个 cfg 开关都不变 → 无 notify
346
+ // → Token 区冻在旧数字上,要等下一次任意 notify(改任务/切开关)才翻新。
347
+ // 为什么用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
348
+ // (引用比较恒真)→ 3s 轮询每轮都 notify,tasksHash 的渲染节约当场作废。JSON 串只在这份聚合
349
+ // 真变了时才不等(KB 级体积、3s 一次,代价可忽略);赋值前先快照,赋值后比对。
350
+ var usageJsonPrev = JSON.stringify(state.usageSummary || null)
351
+ var rgNow = activeRange()
352
+ if (rgKey === (rgNow.from + '~' + rgNow.to)) state.usageSummary = (d && d.usageSummary) || null
353
+ var usageDelta = !tasksChanged && JSON.stringify(state.usageSummary || null) !== usageJsonPrev
320
354
  // 架构健康提示(架构自省 L1):与 tasks 同源透传,HealthHints 组件直接读 state.healthHints,
321
355
  // 不再自持 rpc('get-tasks')——消灭仪表盘打开期间的双轮询。老 host 无此字段 → 空数组零渲染
322
356
  state.healthHints = (d && Array.isArray(d.healthHints)) ? d.healthHints : []
@@ -324,12 +358,16 @@
324
358
  // 缺省兼容——老 host 不返回该字段时置空对象,卡片/详情层遇空一律不渲染相关元素
325
359
  state.childStats = (d && d.childStats) || {}
326
360
  // 学习飞轮 v1 能力检测:老 host 不返回该字段 → 视为开启(默认开);只有显式 false 才关。
327
- state.feedbackEnabled = !(d && d.feedbackEnabled === false)
361
+ state.feedbackEnabled = cfgKnobOf(d, 'feedbackEnabled')
328
362
  // 回执开关(设置区「通知」):同口径——老 host 不返回 → 视为开,只有显式 false 才关
329
- state.notifyDispatch = !(d && d.notifyDispatch === false)
330
- state.notifyDone = !(d && d.notifyDone === false)
363
+ state.notifyDispatch = cfgKnobOf(d, 'notifyDispatch')
364
+ state.notifyDone = cfgKnobOf(d, 'notifyDone')
331
365
  // 史诗拆分总开关(设置区「功能」):同上——老 host 不返回 = 开(引导照旧),只有显式 false 才关
332
- state.epicSplit = !(d && d.epicSplit === false)
366
+ state.epicSplit = cfgKnobOf(d, 'epicSplit')
367
+ // 开关有变 / Token 区聚合有变 → 补一次 notify(tasksChanged 分支已在上面 notify 过,
368
+ // 这里只管 hash 不变时被跳过的那两次:开关乐观更新纠偏 + 统计范围切换后的新聚合)
369
+ var cfgDelta = !tasksChanged && cfgKnobsChanged(cfgKnobs, state)
370
+ if (cfgDelta || usageDelta) notify()
333
371
  applyIsRoot(!d || d.isRoot !== false) // 原始值只喂给防抖器,消费点一律读 isRootStable
334
372
  if (!state.isRootStable && state.open) { state.open = false; state.detailId = null } // 子代理会话(含连续 3 次 false 的真降级):强制收起看板
335
373
  if (d && d.dispatchInfo && d.dispatchInfoAt && Date.now() - new Date(d.dispatchInfoAt).getTime() < 120000) { state.dispatchInfo = d.dispatchInfo } else { state.dispatchInfo = '' } // 瞬时通知 2min 内有效,过期强制清空(服务端写后不清曾致残留数天)
@@ -240,7 +240,7 @@
240
240
  if (r && r.ok === false && r.error === 'touches-conflict') {
241
241
  var ids = Array.isArray(r.conflicts) ? r.conflicts : []
242
242
  var names = ids.map(function (id) { var t2 = getTask(id); return (t2 ? t2.title : id) + ' (' + id + ')' }).join('、')
243
- if (window.confirm('⚠️ 文件锁冲突:以下任务正持有同一批文件(touches 重叠;锁持到归档——verifying/resolved 卡也在持锁,等其归档即自动放行):\n\n' + (names || ids.join('、')) + '\n\n强行并行可能互相覆盖改动/diff 冲突。仍要越权派发吗?')) return send(true)
243
+ if (window.confirm('⚠️ 文件锁冲突:以下任务正持有同一批文件(touches 重叠;in-progress/verifying 卡持锁,状态流转到 resolved/cancelled/归档 即自动放行):\n\n' + (names || ids.join('、')) + '\n\n强行并行可能互相覆盖改动/diff 冲突。仍要越权派发吗?')) return send(true)
244
244
  setActionMsg('⛔ 已取消派发(等文件锁释放,或调整 touches 声明)')
245
245
  return { ok: false, cancelled: true }
246
246
  }
@@ -382,11 +382,15 @@
382
382
  var seq = r.role === 'verifier' ? (++vN) : (++wN)
383
383
  var isCur = (r.role === 'worker' && task.claimedBy === r.id) || (r.role === 'verifier' && task.verifierRun === r.id)
384
384
  var okMark = r.outcome === 'completed' ? ' ✅' : (r.outcome === 'running' ? ' ⏳' : (r.outcome ? ' ⚠' : ''))
385
+ // 续跑标记(卡2 落的 runs[i].resume 字段;卡3 Step3 在此显式标注):同一条会话的第二次及以后
386
+ // 派发是「冷复活续跑」而不是新开 Worker,不标出来会让人把「第 2/3 次」误读成又开了一个新会话。
387
+ // 只读 r.resume,不猜(老留档没有该字段 → 零渲染,与改造前逐字一致)。
388
+ var rsMark = r.resume === true ? ' ↻' : ''
385
389
  return React.createElement('button', {
386
390
  key: i, onClick: function () { if (uiWorkspaceSvc) uiWorkspaceSvc.openSession(r.id) },
387
- title: rm.tip + ' 第 ' + seq + ' 次' + (r.at ? ' · ' + ago(r.at) : '') + (r.model ? ' · ' + r.model : '') + (r.outcome ? ' · ' + r.outcome : '') + '(' + r.id + ')',
391
+ title: rm.tip + ' 第 ' + seq + ' 次' + (r.resume === true ? '(↻ 冷复活续跑:沿用上一次的子会话,非新开)' : '') + (r.at ? ' · ' + ago(r.at) : '') + (r.model ? ' · ' + r.model : '') + (r.outcome ? ' · ' + r.outcome : '') + '(' + r.id + ')',
388
392
  style: { fontSize: 10, padding: '2px 8px', border: '1px solid ' + (isCur ? rm.color : C.border), borderRadius: 3, background: isCur ? C.card : 'transparent', color: rm.color, cursor: 'pointer', fontWeight: isCur ? 600 : 400 }
389
- }, '→ ' + rm.tip + ' #' + seq + (r.at ? ' · ' + ago(r.at) : '') + okMark)
393
+ }, '→ ' + rm.tip + ' #' + seq + (r.at ? ' · ' + ago(r.at) : '') + okMark + rsMark)
390
394
  })))
391
395
  })(),
392
396
  canIntervene ? React.createElement('div', { style: { marginTop: 8, padding: '6px 8px', border: '1px dashed ' + C.warn, borderRadius: 6 } },