dsh-agent-board 1.7.2 → 1.7.4

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
@@ -102,12 +102,15 @@ dsh plugin --profile web remove dsh-agent-board
102
102
 
103
103
  ### 仪表盘(Token 消耗)
104
104
 
105
- - 仪表盘视图新增「Token 消耗」区:本看板累计**有效 / 缓存读 / 合计**三分量 + 输入 / 输出(缓存写非零时一并展示)拆分、按模型分布条形图、任务消耗 **Top 8**(标题可点击直达该任务详情,title 附「有效 N / 缓存读 M」拆分);大数字为**今日有效消耗**(本地日口径,无日账则显示 0,旁附「含缓存读共 X」小字对照,有效值不可知时以总量近似并标 `~`)、旁附「累计(本看板)」三分量小字,下方「近 7 天」迷你条形按日展示近 7 天**有效消耗**(今天高亮,title 补总量对照,7 天全为 0 时不渲染);进行中的卡片右上角显示本任务已累计消耗(`⛁ 数字`);区底固定一行口径 caption:**仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话**
105
+ - 仪表盘视图新增「Token 消耗」区:本看板累计**有效 / 缓存读 / 合计**三分量 + 输入 / 输出(缓存写非零时一并展示)拆分、按模型分布条形图、任务消耗 **Top 8**(标题可点击直达该任务详情,title 附「有效 N / 缓存读 M」拆分);大数字为**今日有效消耗**(本地日口径,无日账则显示 0,旁附「含缓存读共 X」小字对照,旧口径日有效值不可知时显示「—」并在 title 说明,**不拿总量冒充有效值**)、旁附「累计(本看板)」三分量小字,下方「近 7 天」迷你条形按日展示近 7 天**有效消耗**(今天高亮,title 补总量对照,旧口径日画矮灰柱并标「旧口径数据(仅总量)」,7 天全为 0 时不渲染);进行中的卡片右上角显示本任务已累计消耗(`⛁ 数字`);区底固定口径 caption:累计 / Top 8 仅看板派发的 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(选范围即渲染,不等下一次任意刷新)。**Token 口径三连修(`task-muwsnyqv`)**:① 范围内 `byDay` 按入选 run 的本地日落点逐日重建(`to` 端闭区间,`from=to=今天` 时今天不丢——修「范围含今天时 total 含今天、byDay 却吞今天」);② `usageSummary` 同时返回**未过滤全量 `byDayFull`** 与范围内 `byDay`,「今日」「近 7 天」改读 `byDayFull` 兑现固定口径承诺(老 host 缺字段退化读 `byDay`);③ **旧口径日(`e=null`:老 number 日账 / 存量兜底,只有含缓存读的总量)不拿总量冒充有效值**——今日大数字显示「—」+ title 说明,近 7 天画矮灰柱 + tooltip 标「旧口径数据(仅总量,含缓存读)」,柱高归一只按有效值(此前 154M 含 97% 缓存读的旧日账会被画成巨柱、把其余日子的有效柱压平)
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
- - `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 时一律显示「暂无数据」
110
- - 统计口径边界:本区只统计**看板派发的 Worker/Verifier run**,**不含主窗口对话本身**(不越界统计,UI caption 已明示)
110
+ - `byDay` 为**双指标**形态 `{ t, e }`(`t` = 总量含缓存读,`e` = 有效消耗不含缓存读;一次 run 不跨日拆分)。聚合端兼容老数据:值是裸 number 的历史日账按 `{ t: n, e: null }` 处理——总量照常保留(「近 7 天」不会整段消失),有效值不可知就置 `null`;**旧口径日(`e=null`)UI 不拿总量冒充有效值**(`task-muwsnyqv`):今日大数字显「—」、近 7 天画矮灰柱 + tooltip 标「旧口径数据(仅总量)」,总量仅在「含缓存读共 X」小字与悬浮 title 里单列对照
111
+ - `get-tasks` 再现算 board 级 `usageSummary`(总量 / 有效合计 / 按模型(合计 `byModel` + 有效分摊 `byModelEff`,两者同源累加)/ Top8(带有效与缓存读拆分)/ 日账双份(范围内 `byDay` + 未过滤全量 `byDayFull`,后者供「今日」「近 7 天」固定口径读取),不落盘额外表;老任务无 `byDay` 时整笔近似归到 `updatedAt` 的本地日,无 `updatedAt` 则不计入任何日)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」;第二参 `range`(可选 `{ from, to }` 本地日)给定即只聚合该范围内的 run(口径见上条)
112
+ - **主窗口(本会话对话)消耗单列**(`task-muwsol23`):累计行下方新增一行「**主窗口(本会话):今日有效 X · 累计 Y(缓存读 Z)**」,按模型分布尾部追加一条「**主窗口(对话)**」(琥珀色与 run 模型区分,有效为 0 不渲染,固定尾部不参与排序);数据源是该看板所属主会话自己的 v4 日志,host 侧**增量尾读**聚合(缓存键=会话 id:文件不变零读、变大只读新增字节段、变小/轮换全量重读一次,尾部半帧不结算下轮补读——3s 轮询稳态零全量重读),五分量与有效口径(e=输入+输出+缓存写)同 Worker 完全一致;**隔离红线:不进 Top 8、不进「本看板累计」、不进架构健康/学习飞轮基数**,只并列展示;随统计范围裁剪(按 `assistant/message` 事件的本地日落点);日志不存在/读取失败/无 usage 一律**静默降级**(不渲染该行,不炸轮询)
113
+ - 统计口径边界:本区累计 / Top 8 / 架构健康与学习飞轮基数只统计**看板派发的 Worker/Verifier run**,**不含主窗口对话**;主窗口对话消耗按上条单列一行 + 模型分布尾部一条并列展示(不越界混入,UI caption 已明示)
111
114
  - 「架构健康」区(架构自省 L1):`get-tasks` 顺带对**近 50 张卡**现算四信号(纯函数零存储:touches 声明热度 ≥8 次且占比 ≥40% / 带 touches 任务滞留中位数 >2 倍 / 任务**执行**时长 p90 >45min(claimedAt→resolvedAt 纯干活口径,不含排队)/ 同路径驳回 ≥2 次),命中才在仪表盘渲染提示条(⚠️/ℹ️ 两级,最多 3 条)——让运行数据主动提示"该优化架构了"(如某文件反复成为锁热点=该拆),信号只建议不裁判
112
115
  - 统计区耗时同口径拆分:「平均排队 / 平均执行」双行展示(平均验收单列不变)
113
116
  - 统计区新增「调研 ROI」对比行:resolved/archived 卡按有无调研注入分组现算卡数 / 平均执行时长 / 平均 token(双组总样本 ≥4 才渲染,无调研组明显更慢时数字 warn 色提示)
@@ -133,7 +136,7 @@ draft → pending → in-progress → verifying → resolved → archived
133
136
  - **依赖调度**:`dependsOn` 声明依赖(DFS 环检测),依赖全部完成后才会被派发,串行链路自动编排
134
137
  - **管线分档**:`full`(执行+验证)/ `work`(只做不验)/ `direct`(不进池,主窗口直接处理),创建时按规则自动分类、可手动覆盖
135
138
  - **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
136
- - **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`/`resolved`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁持到归档**:验收通过后继续持锁(护住「验收后-提交前」的提交窗口期),归档(含批量归档)才真释放,`cancelled` 立即放锁(放弃语义=不再产出)——避免并行 Worker 改同一批文件互踩。手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
139
+ - **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁随工作态**:状态流转到已完成(`resolved`)即放锁,`cancelled`/归档同样不再持锁(归档回归纯收纳动作、不再是释放点)——锁只护「正在写」的阶段,「验收后-提交前」的窗口期由主窗口「回执到即提交」纪律 + 史诗 post-hook 承接,不用长持锁把整批串行化。避免并行 Worker 改同一批文件互踩;手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
137
140
  - **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
138
141
  - **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
139
142
  - **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档(僵尸态出清:`archive-task` 对「无活跃 run 的 in-progress」——典型如被 parentKick 推进后子任务已全部归档的史诗——直接放行,有活跃 run 的仍拒);**归档子任务仍计入史诗进度并在详情留档可见**——进度分子口径 `settled = resolved | cancelled | archived`、分母也含归档,归档一张子卡不会再让史诗进度从 `0/10` 退化成 `0/9`(进度只增不减),全归档的父卡也照常显示徽章;详情子任务清单不排归档行(灰化 + 行尾「已归档」徽章 + 沉底排序,点击仍可进子卡看留档)
@@ -150,11 +153,11 @@ draft → pending → in-progress → verifying → resolved → archived
150
153
 
151
154
  ### 一次性派发(v74 去池化)
152
155
 
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 汇总提醒主窗口(全有不打扰);派发时读包失败落任务「最近失败」行,不再静默
156
+ - 每个任务 spawn 一个**一次性子代理**(Worker/Verifier),上下文全量注入 prompt,做完即销毁——无常驻池、无池化状态残留(Worker 的可续跑形态见上一节「可续跑 Worker」,Verifier 与 hook run 恒为一次性)
157
+ - **预研上下文注入(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 字数(无则明示)
158
+ - **锚点行段与用途**:`contextFiles` 条目写法「`path:L2350-L2420` / `path:L2350`」+ 可选「` — 一句用途`」(em dash 两侧空格分隔;缺省只给路径行号)——锚点只认尾部 `:L<行号>`(兼容 Windows 盘符),清单里原样带上行号供子代理直接按行段 read;锚点写错(`:L0` / `:L5-L2`)自动剥掉,不误导子代理去读空段
159
+ - **按需自取代替 host 预切段**:派发侧零读盘(不再切行段、不再附结构索引块)——子代理自己 `read(path, offset, limit)` 取需要的那段,清单里给出的行号就是起点;`task_preview_context` / `preview-context` 返回的也是这份瘦身清单(不是文件正文)
160
+ - **派发调研门禁(warning 族,软提示不阻断)**:`task_create`/`create-task` 响应附 `warning` 字段——①描述为空「Worker 只能凭标题猜需求」②full/work + touches 非空而未附调研上下文 ③touches 含整树 glob 建议精确到文件级(可多条合并);GUI 表单内黄色展示不关窗。epic 发布(publish)时自动轻量预检全部子任务注入情况,缺材料则 pushSysNote 汇总提醒主窗口(全有不打扰);派发时清单组装失败落任务「最近失败」行,不再静默
158
161
  - **Worker/Verifier 均可配置异构模型**(⚙️ 弹出层下拉选择,空 = 继承父级),避免同源盲点;模型故障自动熔断回退父级模型
159
162
  - 孤儿回收:子代理 run 结束/丢失超 2 分钟 → 任务自动回待办重派
160
163
  - 看门狗:运行超时且事件流停滞 → 标记"疑似卡死"(不自动杀,裁决权交主窗口/用户)
@@ -163,6 +166,21 @@ draft → pending → in-progress → verifying → resolved → archived
163
166
  - 手动派发:详情页「派发 / 派发验收」按钮可随时手动触发单任务派发(auto 模式补派、manual 模式主通道)
164
167
  - 会话隔离:看板按会话分桶,多会话互不干扰
165
168
 
169
+ ### 可续跑 Worker(continuable,卡1~卡3 已落地,默认开)
170
+
171
+ **一句话**:Worker 从「一次性 run」变成**持久子会话**——超时不再丢现场,重派时原会话**冷复活**接着干;Verifier 与 hook run 仍是一次性。
172
+
173
+ - **continuable 化**:Worker 走 `subagents.startContinuable`(rec 持 `childId`),turn 结束改由 host 事件 `agent/status` 的 `running→idle` 观测(一次性路径本就用 `run.result` 结算,未受影响);`claimedBy` / `t.runs[].id` / 详情页会话跳转的 id 语义不变(仍是子会话 id)
174
+ - **硬超时 interrupt 留存(不销毁)**:失败/硬超时结算时只对子会话发取消信号打断当前 turn——Activation、未认领收件箱、已发布后代全部保留,子会话 idle 后仍可被唤醒;`interrupt` 失败(会话已死/无权限)绝不阻断结算:任务照常回待办,只给该 run 落 `noResume` 标记(重派直接起新 Worker,不空唤醒)
175
+ - **重派冷复活续跑**:命中「上次 continuable Worker 结局=超时/失败」的待办卡时,不 spawn 新会话,改 `subagents.sendMessage(活父 Agent, childId, 断点续跑指令)`——子会话带着上一轮全部上下文复活,先盘点工作树再从断点继续;`t.runs[]` 追加一条 `resume: true` 记录(详情页历史会话按钮带 **↻** 标记),续跑同样计入三连败计数;续跑不可用(`NOT_RESUMABLE` 等)→ 回落全新 Worker,并把原因写进任务消息随首条 prompt 注入
176
+ - **续跑基线(不拿旧文本冒充交付物)**:续跑轮结算只认**基线字节之后**新写的助手文本——一个字没产出就走「空文本按失败」重排,绝不把上一轮(被中断那次)的残留文本当成本轮交付物推进验收
177
+ - **重启 reconcile(找回活跃续跑 Worker)**:host 重启会清空内存里的活跃 run 表,但持久子会话还活着。首轮派发周期对「进行中且无活跃 run」的卡查一次 `listChildren(root)`:仍在列 → 重建 rec 观测(监听器本就在)并重挂两级超时臂、基线取当前日志字节数;不在列 → 视为会话已死,走硬超时等价物(回待办重排 + 留一行流转记录),不占 Worker 并发位
178
+ - **usage 增量计账(按 seq 水位线)**:注意「一个持久子会话被结算多次」是新形态——整份日志全量累加会把前几轮的 token 反复记账(实测同一 childId 结算两次=双倍)。现按 v4 日志事件自带的 `seq` 记水位线(落在 `t.runs[].usageSeq`):每次结算只累加水位线之后的 `assistant/message` 增量,本轮没新增量就一行都不记;one-shot 路径(每 run 独立日志)行为逐字不变
179
+ - **开关 `workerContinuable`(默认开)**:关掉即逐字回退旧的一次性路径(零 `startContinuable`/零 `sendMessage`/零 `interrupt`,结算仍走 `run.result`+`dispose`);Verifier 与 hooks(`hook-pre`/`hook-post`)**保持一次性**,不受该开关影响
180
+ - **结算双通道 + 池韧性(2026-10-06 事故修复)**:continuable 结算有事件通道(`agent/status` 的 running→idle,身份取 `agent.session.id`)与上报通道(`board_report` 落定即收尾)两条入口,任一到达即关账(结局/usage/超时臂三件套,幂等);派发周期自带**幽灵活跃表项 GC**(卡面证据核对回收残留 rec,防残留把派发容量顶到 0 拖死全池)+ 整轮 try/catch 与逐卡隔离(单点异常只作废该卡该轮)+ 去抖 latch 时间戳兜底复位
181
+ - **续跑指令优先级**:断点续跑指令会带上卡上 messages 原文(仲裁/干预/驳回理由),并声明**最新裁决/干预优先于历史原始契约**(冲突以最新为准)——冷复活子会话的历史里没有仲裁答案,不带原文它无从知晓
182
+ - **高优干预实时送达 continuable Worker**:`task_intervene` 对 continuable rec 走宿主投递通道(保留插件 source,下一个 step 边界消费),会话不可用降级 `sendMessage` 冷复活投递,再不行回退「记录注入随重派送达」并在 history 注明
183
+
166
184
  ### 工作模式(三档)
167
185
 
168
186
  看板上一个选择器切换三档工作模式(RPC 单入口 `set-work-mode`,`mode` = `list` / `auto` / `team`):
@@ -177,7 +195,7 @@ draft → pending → in-progress → verifying → resolved → archived
177
195
 
178
196
  - **歧义裁决**:Worker 遇歧义不猜测,一律上报;裁决后新 Worker 携带答案接手(Team 托管档附带 system prompt 派发引导 + 默认草稿护栏)
179
197
  - **Verifier 验收**:`acceptance` 硬性验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑;跨档一致
180
- - **touches 排他**:`touches` 文件级排他锁在活动任务间生效,冲突任务跳过本轮派发,**锁持到归档**(`in-progress`/`verifying`/`resolved` 持有,归档释放,`cancelled` 立即释放);跨档一致
198
+ - **touches 排他**:`touches` 文件级排他锁在活动任务间生效,冲突任务跳过本轮派发,**锁随工作态**(`in-progress`/`verifying` 持有;状态流转到 `resolved` 即释放,`cancelled`/归档同样释放——归档不再承担解锁职责);跨档一致
181
199
  - 孤儿回收、看门狗、级联归档、会话隔离同样三档一致
182
200
 
183
201
  Team 托管档独有(调度员体验):
@@ -228,11 +246,11 @@ v1.6.0 起 host 端从单体 index.mjs(1487 行)拆为薄壳 + 7 个领域
228
246
  | 模块 | 域 | 内容 |
229
247
  |---|---|---|
230
248
  | `policy.mjs` | 策略层 | 粒度治理软闸门 + 学习飞轮候选教训(纯函数零状态) |
231
- | `usage.mjs` | 统计 | v4 会话日志定位 / zstd 分帧 / token usage 聚合(纯函数;有效消耗 `effectiveTokens` + `byDay` 双指标 `{t,e}` 聚合,兼容老 number 日账) |
249
+ | `usage.mjs` | 统计 | v4 会话日志定位 / zstd 分帧 / token usage 聚合(纯函数;有效消耗 `effectiveTokens` + `byModelEff` 模型有效分摊 + `byDay` 双指标 `{t,e}` 聚合,兼容老 number 日账;统计范围按任务级 `t.runs[].at` 逐 run 裁切;`sinceSeq` 水位线增量结算——同一持久子会话多次结算不重复计账;`readMainWindowUsage` 主窗口会话**增量尾读**聚合——文件偏移水位缓存,追加只读增量、截断全量重读、尾部半帧不结算) |
232
250
  | `session.mjs` | 会话 | root 解析缓存 / 会话 id 归一 / workMode 派生 / runsFor |
233
251
  | `store.mjs` | 持久化 | boardPath / rt / wt 原子落盘 / 跨重启继承 / fileLocks 串行化 / mutateLocked |
234
252
  | `notify.mjs` | 通知 | makeMsg / 歧义 25s 去抖 / 回执聚合 + 空闲门控 / 投递前过滤 |
235
- | `dispatch.mjs` | 派发引擎 | poolCycle / spawnOneShot / settleRun / 两级超时 / 孤儿回收 |
253
+ | `dispatch.mjs` | 派发引擎 | poolCycle / spawnOneShot / settleRun / 两级超时 / 孤儿回收 / 可续跑 Worker(continuable + 重启 reconcile + usage 水位线) |
236
254
  | `rpc.mjs` | 接口层 | RPC 路由 + 13 个 Agent 工具注册 |
237
255
 
238
256
  (store→dispatch 的循环依赖由 `deps.poolCycle` 晚绑定解开;index.mjs 对外 re-export 契约不变。)
package/index.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // 兼容 re-export:对外导出契约不变(单测与历史引用直接 import 自本文件)。
17
17
  export { TASK_SIZE_CONTRACT, SUGGEST_SPLIT_TEXT, TEAM_SPLIT_RULE, suggestSplitOf, withSplitHint, splitRuleOf } from './lib/policy.mjs'
18
- export { findRunLog, readRunUsage, aggregateUsageSummary, effectiveTokens, taskEffectiveTokens } from './lib/usage.mjs'
18
+ export { findRunLog, readRunUsage, aggregateUsageSummary, effectiveTokens, taskEffectiveTokens, readMainWindowUsage } from './lib/usage.mjs'
19
19
  import { createSession } from './lib/session.mjs'
20
20
  import { createStore } from './lib/store.mjs'
21
21
  import { createNotify } from './lib/notify.mjs'
@@ -35,15 +35,18 @@ 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)
46
47
  sysNotesBuf: {}, // 系统级异常通知队列(随回执冲刷)
48
+ mainWindowUsageCache: {}, // 主窗口消耗增量尾读缓存(task-muwsol23):sid → { size, mtimeMs, 聚合五分量+byDay },
49
+ // 文件不变零读 / 变大只读增量 / 变小全量重读一次;纯内存不落盘,重启自然全量一次
47
50
  }
48
51
  // ===== 模块接线(依赖顺序:session → store → notify → dispatch → rpc)=====
49
52
  var session = createSession(ctx, state)
@@ -54,7 +57,8 @@ export function apply(ctx) {
54
57
  var notify = createNotify(ctx, state, { rt: store.rt, rootForSession: session.rootForSession, withTimeout: session.withTimeout, isDispatched: session.isDispatched })
55
58
  var dispatch = createDispatch(ctx, state, {
56
59
  rt: store.rt, wt: store.wt, mutateLocked: store.mutateLocked, kickCycle: store.kickCycle,
57
- rootForSession: session.rootForSession, sessionCwd: session.sessionCwd,
60
+ rootForSession: session.rootForSession,
61
+ // sessionCwd 不再注入 dispatch(预研清单瘦身后派发侧不读盘);rpc 侧仍需要(epic 预检路径存在性)
58
62
  withTimeout: session.withTimeout, runsFor: session.runsFor, feedbackOn: session.feedbackOn, epicSplitOn: session.epicSplitOn,
59
63
  pushSysNote: notify.pushSysNote, maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
60
64
  notifyDispatched: notify.notifyDispatched,
@@ -71,6 +75,10 @@ export function apply(ctx) {
71
75
  maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
72
76
  pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
73
77
  spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
78
+ // 上报通道结算入口(task-muwkhqf8):board_report/board_verdict 把任务推进到落定态时,
79
+ // 由 rpc 侧顺手对「该任务当前的 continuable Worker rec」补做收尾三件套
80
+ // (关 run 结局 / usage 落账 / 摘超时臂)——治「工具上报完成后 idle 事件没到 → run 永远 running」。
81
+ reportRunSettled: dispatch.settleReportedRun,
74
82
  hasActiveRun: hasActiveRun,
75
83
  })
76
84
 
@@ -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() {
@@ -230,10 +237,11 @@
230
237
  // usageSummary 由 host 从各任务 t.usage 现算(t.usage 来自 Worker/Verifier 会话 v4 日志的
231
238
  // assistant/message.usage 聚合)。这里只做展示、不做计费断言;日志读不到/还没有 run 结算时
232
239
  // usageSummary.total 为 0,一律显示「暂无数据」。
233
- // 日账(usageSummary.byDay:{'YYYY-MM-DD': {t,e}})用于「今日」大数字与「近 7 天」条形。
240
+ // 日账(usageSummary.byDayFull:{'YYYY-MM-DD': {t,e}})用于「今日」大数字与「近 7 天」条形。
234
241
  // 双指标口径:t = 总量(含缓存读)、e = 有效消耗(输入+输出+缓存写,不含缓存读)。
235
242
  // e === null 表示该日只有老形态 number 日账 / 存量兜底(没有逐 run 拆分,有效值不可知)——
236
- // 展示时退化为 t 并在文案/title 上标 ~ 近似,绝不把总量冒充有效值。
243
+ // 旧口径日不拿 t 冒充有效(task-muwsnyqv ③:本板 10-05 t=154M 含 97% 缓存读曾被画成巨柱):
244
+ // 今日大数字显示「—」+ title 说明,近 7 天画矮灰柱 + tooltip 标「旧口径数据(仅总量)」。
237
245
  // dayKey 口径与 host 记账完全一致——本地 getters 拼,绝不用 toISOString()(UTC 会让
238
246
  // 晚上 8 点后的消耗落到次日,今日消耗直接错位)。
239
247
  function localDayKey(d) {
@@ -268,69 +276,129 @@
268
276
  function TokenUsage(props) {
269
277
  var u = props.usage
270
278
  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 消耗')
272
- 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
277
- // 日账:今日数字取本地日 key,没有日账(byDay 缺字段/老 host)时退化为 0,不炸也不误报。
279
+ // 范围激活标记:Token 区的模型分布/Top8/累计已按「统计范围」过滤(host 侧重算),
280
+ // 不加标记的话用户没法判断看到的数字是全量还是范围内——标记与 RangeFilter 同源(activeRange)。
281
+ var tg = activeRange()
282
+ var tgOn = !!(tg.from || tg.to)
283
+ 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 消耗',
284
+ 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)
285
+ // ===== 主窗口(本会话对话)消耗(task-muwsol23):host 增量尾读主会话 v4 日志聚合 =====
286
+ // 隔离口径:只在 Token 区单列一行 + 模型分布尾部追加一条;不进累计 / Top 8 / 模型归属摊派。
287
+ // 缺字段(老 host)/ 读不到日志 / 无 usage → mw=null → 两处都不渲染(静默降级,不炸轮询)。
288
+ var mw = (u && u.mainWindow && typeof u.mainWindow === 'object' && typeof u.mainWindow.effective === 'number') ? u.mainWindow : null
289
+ var todayKey0 = localDayKey()
290
+ var mwToday = 0
291
+ if (mw && mw.byDay && typeof mw.byDay === 'object') {
292
+ var mwc0 = mw.byDay[todayKey0]
293
+ mwToday = (mwc0 && typeof mwc0.e === 'number') ? mwc0.e : 0
294
+ }
295
+ // 主窗口单列行(累计行下方):「主窗口(本会话):今日有效 X · 累计 Y(缓存读 Z)」
296
+ function mwLine() {
297
+ if (!mw) return null
298
+ return React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 8 }, title: '主窗口(本会话对话)消耗:host 增量尾读本会话 v4 日志聚合,有效口径 = 输入+输出+缓存写(不含缓存读)' + (tgOn ? ';随统计范围裁剪(按助手消息的本地日落点)' : '') + '。与看板派发口径并列展示——不进累计 / Top 8 / 模型归属摊派' },
299
+ '主窗口(本会话):今日有效 ',
300
+ React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(mwToday)),
301
+ ' · 累计 ',
302
+ React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(mw.effective)),
303
+ '(缓存读 ',
304
+ React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(mw.cacheRead)),
305
+ ')')
306
+ }
307
+ if (!u || !u.total) return React.createElement('div', { style: box }, head, React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据(Worker/Verifier 会话日志里还没有 usage 记录)'), mwLine())
308
+ // ===== 主数字口径统一(task-muwq9u04):模型分布行与 Top8 行的主数字一律显示**有效消耗**(e),
309
+ // 含缓存读的合计(t)退到 title 悬浮;否则同一块里「今日有效 3.7M」与「单任务 21M」并排自相矛盾
310
+ // (用户就是这么判成 bug 的)。数据源:host 的 byModelEff(模型有效分摊)与 topTasks[].effective;
311
+ // 老 host 缺 byModelEff / 老卡无五分量留账 → 退化用合计值并在 title 标 ~(口径不伪造)。
312
+ // 排序按**显示口径**(有效)降序:条形长度与行序一致,否则首行不是最长的条。
313
+ var models = Object.keys(u.byModel || {}).map(function (m) {
314
+ var tt = u.byModel[m] || 0
315
+ var ee = (u.byModelEff && typeof u.byModelEff[m] === 'number') ? u.byModelEff[m] : null
316
+ return { model: m, total: tt, eff: (ee === null ? tt : ee), approx: ee === null }
317
+ }).sort(function (a, b) { return b.eff - a.eff })
318
+ var maxM = models.length ? (models[0].eff || 1) : 1
319
+ // 主窗口行与 run 模型同一把尺(参与归一,条形长度才可比),但固定尾部追加、不参与排序(见渲染处)
320
+ if (mw && mw.effective > maxM) maxM = mw.effective
321
+ var top = (u.topTasks || []).map(function (x) {
322
+ var ee = (typeof x.effective === 'number') ? x.effective : null
323
+ 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 }
324
+ }).sort(function (a, b) { return b.eff - a.eff })
325
+ var maxT = top.length ? (top[0].eff || 1) : 1
326
+ // 日账:今日数字取本地日 key,没有日账(缺字段/老 host)时退化为 0,不炸也不误报。
278
327
  // 读侧兼容两种单元形态:老 number(只有总量,有效值不可知)→ { t: n, e: null }。
279
- var byDay = (u.byDay && typeof u.byDay === 'object') ? u.byDay : {}
328
+ // 固定口径数据源(task-muwsnyqv ②):「今日」「近 7 天」读**未过滤**的 byDayFull——caption
329
+ // 承诺这两处不随统计范围变,读范围内 byDay 会让「今天被范围裁掉」时大数字凭空归零(实证)。
330
+ // 老 host 缺 byDayFull → 退化读 byDay(范围内口径,数值可能偏小但形态一致,不炸)。
331
+ var byDay = (u.byDayFull && typeof u.byDayFull === 'object') ? u.byDayFull : ((u.byDay && typeof u.byDay === 'object') ? u.byDay : {})
280
332
  function dayOf(v) {
281
333
  if (v && typeof v === 'object') return { t: Number(v.t) || 0, e: (v.e === 0 || v.e) ? Number(v.e) || 0 : null }
282
334
  return { t: Number(v) || 0, e: null }
283
335
  }
284
336
  var todayKey = localDayKey()
285
337
  var todayCell = dayOf(byDay[todayKey])
286
- // 有效消耗缺失(老日账/存量兜底)→ 大数字退化为总量并打 approx 标 ~,口径不伪造
287
- var todayApprox = todayCell.e === null
288
- var todayEff = todayApprox ? todayCell.t : todayCell.e
338
+ // 旧口径日(e===null 且有总量:老 number 日账/存量兜底,只有含缓存读的总量)不拿 t 冒充有效
339
+ // (task-muwsnyqv ③)——大数字显示「—」+ title 说明;t=0 且无日账 = 今日尚无结算,照常显示 0。
340
+ var todayLegacy = todayCell.e === null && todayCell.t > 0
341
+ var todayEff = todayCell.e === null ? 0 : todayCell.e
289
342
  var days = lastNDays(7)
290
343
  var maxDay = 1
291
344
  var hasDayData = false
292
- for (var di = 0; di < days.length; di++) { var dc = dayOf(byDay[days[di]]); var dv = (dc.e === null ? dc.t : dc.e); if (dv > maxDay) maxDay = dv; if (dc.t > 0 || dc.e > 0) hasDayData = true }
345
+ var hasLegacy = false
346
+ // maxDay 只按有效消耗归一:旧口径日(e===null)的 t 含缓存读(本板实测占 97%),参与归一会
347
+ // 把其余日子的有效柱压成平地(task-muwsnyqv ③:10-05 t=154M 巨柱就是这么来的)。
348
+ for (var di = 0; di < days.length; di++) { var dc = dayOf(byDay[days[di]]); if (dc.e !== null && dc.e > maxDay) maxDay = dc.e; if (dc.e === null && dc.t > 0) hasLegacy = true; if (dc.t > 0 || dc.e > 0) hasDayData = true }
293
349
  function mini(label, value) { return React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, label + ' ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(value))) }
294
350
  // 累计三分量:有效(真实成本)+ 缓存读(占了 total 的大头,必须单列才看得出虚高来源)+ 合计
295
351
  var effTotal = (typeof u.effective === 'number') ? u.effective : ((u.input || 0) + (u.output || 0) + (u.cacheWrite || 0))
296
352
  return React.createElement('div', { style: box },
297
353
  head,
298
354
  React.createElement('div', { style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', marginBottom: 8 } },
299
- React.createElement('span', { style: { fontSize: 20, fontWeight: 700, color: C.brand }, title: '今日有效消耗(本地日 ' + todayKey + ')= 输入+输出+缓存写,不含缓存读;一次 run 的消耗整笔记在结算日' + (todayApprox ? '。本条日账来自老形态/存量兜底数据,有效值不可知,此处以总量近似(标 ~)' : '') }, (todayApprox ? '~' : '') + fmtTokens(todayEff)),
300
- React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, 'tokens(今日有效' + (todayApprox ? ' · 近似' : '') + ')'),
301
- todayCell.t > todayEff ? React.createElement('span', { style: { fontSize: 10, color: C.text2 }, title: '今日总量(含缓存读)——与有效消耗的差额就是缓存读' }, '含缓存读共 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(todayCell.t))) : null,
355
+ React.createElement('span', { style: { fontSize: 20, fontWeight: 700, color: todayLegacy ? C.text2 : C.brand }, title: todayLegacy ? ('今日有效消耗不可知:该日账为旧口径数据(仅总量 ' + fmtTokens(todayCell.t) + ' tok,含缓存读),总量不冒充有效值') : ('今日有效消耗(本地日 ' + todayKey + ')= 输入+输出+缓存写,不含缓存读;一次 run 的消耗整笔记在结算日') }, todayLegacy ? '—' : fmtTokens(todayEff)),
356
+ React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, 'tokens(今日有效' + (todayLegacy ? ' · 旧口径不可知' : '') + ')'),
357
+ todayCell.t > todayEff ? React.createElement('span', { style: { fontSize: 10, color: C.text2 }, title: '今日总量(含缓存读)——与有效消耗的差额就是缓存读' + (todayLegacy ? ';该日为旧口径数据,有效消耗不可知' : '') }, '含缓存读共 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(todayCell.t))) : null,
302
358
  React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, '累计(本看板) 有效 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(effTotal)), ' · 缓存读 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(u.cacheRead)), ' · 合计 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(u.total))),
303
359
  mini('输入', u.input), mini('输出', u.output), u.cacheWrite ? mini('缓存写', u.cacheWrite) : null),
360
+ // 主窗口(本会话对话)单列行:累计行下方,与看板派发口径并列不混入(mw=null 静默不渲染)
361
+ mwLine(),
304
362
  // 近 7 天迷你条形:高按区间 max 归一(今天高亮 brand,其余浅底 + 边框),
305
- // 柱高一律取**有效消耗**;e 不可知的日退化为总量(title 标 ~ 近似)。
306
- // 7 天全为 0 时整块不渲染(零残留,不占版面)
363
+ // 柱高一律取**有效消耗**;e 不可知的旧口径日不拿总量冒充——画固定矮灰柱(tooltip 标明
364
+ // 「旧口径数据(仅总量,含缓存读)」),有效柱高不被缓存读撑歪。7 天全为 0 时整块不渲染。
307
365
  hasDayData ? React.createElement('div', { style: { marginBottom: 8 } },
308
- React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '近 7 天(有效消耗)'),
366
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '近 7 天(有效消耗)', hasLegacy ? React.createElement('span', { style: { fontSize: 9, fontWeight: 400, marginLeft: 6 } }, '灰柱 = 旧口径数据(仅总量)') : null),
309
367
  React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 4 } },
310
368
  days.map(function (k) {
311
369
  var c = dayOf(byDay[k])
312
- var approx = c.e === null
313
- var v = approx ? c.t : c.e
370
+ // 旧口径日(e===null 且有总量):不拿 t 冒充有效画柱(task-muwsnyqv ③)——
371
+ // 画固定矮灰柱 + tooltip 标明;有效日柱高照常按有效消耗归一。
372
+ var legacy = c.e === null && c.t > 0
373
+ var v = legacy ? 0 : (c.e || 0)
314
374
  var isToday = k === todayKey
315
- var h = v > 0 ? Math.max(3, Math.round(v / maxDay * 32)) : 3
316
- return React.createElement('div', { key: k, title: k.slice(5) + ':有效 ' + String(v) + (approx ? '(近似:老日账只有总量)' : '') + ' tok' + (c.t > v ? ' / 含缓存读共 ' + String(c.t) + ' tok' : ''), style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 } },
317
- React.createElement('div', { style: { width: '100%', height: h, background: isToday ? C.brand : C.nested, border: '1px solid ' + (isToday ? C.brand : C.border), borderRadius: 2 } }),
375
+ var h = legacy ? 6 : (v > 0 ? Math.max(3, Math.round(v / maxDay * 32)) : 3)
376
+ return React.createElement('div', { key: k, title: legacy ? (k.slice(5) + ':旧口径数据(仅总量 ' + String(c.t) + ' tok,含缓存读;有效消耗不可知,不拿总量画柱)') : (k.slice(5) + ':有效 ' + String(v) + ' tok' + (c.t > v ? ' / 含缓存读共 ' + String(c.t) + ' tok' : '')), style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 } },
377
+ React.createElement('div', { style: { width: '100%', height: h, background: legacy ? C.border : (isToday ? C.brand : C.nested), border: '1px solid ' + (legacy ? C.border2 : (isToday ? C.brand : C.border)), borderRadius: 2 } }),
318
378
  React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2, whiteSpace: 'nowrap' } }, k.slice(5)))
319
379
  }))) : null,
320
380
  React.createElement('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
321
381
  React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
322
- React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布'),
382
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布(有效消耗)'),
323
383
  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' })
325
- })),
384
+ 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' })
385
+ }),
386
+ // 模型分布尾部追加「主窗口(对话)」一条(task-muwsol23):有效为 0 不渲染;
387
+ // 琥珀色与 run 模型(brand)区分,固定尾部不参与排序,与派发口径并列不混入
388
+ (mw && mw.effective > 0) ? React.createElement(UsageRow, { key: 'main-window', label: '主窗口(对话)', value: mw.effective, max: maxM, color: C.warn, title: '主窗口(对话):有效 ' + String(mw.effective) + ' tokens(不含缓存读)· 缓存读 ' + String(mw.cacheRead) + ' tokens——本会话对话消耗,与看板派发口径并列不混入(不进累计 / Top 8)' }) : null),
326
389
  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'),
390
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8(有效消耗)'),
328
391
  top.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : top.map(function (x) {
329
392
  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 })
393
+ 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
394
  }))),
332
- // 口径边界:本区只统计看板派发的 Worker/Verifier run,主窗口对话自身不越界纳入
333
- React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话;大数字与「近 7 天」为有效消耗(输入+输出+缓存写,不含缓存读),缓存读单列'))
395
+ // 口径边界:累计 / Top 8 只统计看板派发的 Worker/Verifier run;主窗口对话消耗自 task-muwsol23 起
396
+ // 单列一行 + 模型分布尾部一条并列展示(不越界混入累计/Top8/归属摊派),caption 必须讲清并列关系。
397
+ // 范围说明(task-muwc7hjd):用户常把 RangeFilter 当成「整页过滤」,但今日大数字与近 7 天柱子
398
+ // 是自身固定口径(今日=本地今天、近 7 天=最近 7 个本地日)——不随范围变,必须在文案里讲清,
399
+ // 否则「选了范围数字没变」看起来像 bug。模型分布/Top8/累计才是范围生效的三处。
400
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:累计与 Top 8 仅看板派发的 Worker/Verifier run 消耗;主窗口行=本会话对话消耗,与看板派发口径并列不混入(单列一行 + 模型分布尾部一条,不进累计 / Top 8);主数字(模型分布 / Top 8)与「今日」「近 7 天」均为有效消耗口径(输入+输出+缓存写,不含缓存读),含缓存读的合计在悬浮 title 里单列对照'),
401
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2, lineHeight: 1.5 } }, '统计范围作用于按模型分布 / 任务消耗 Top 8 / 累计三分量(按 run 的本地日落点过滤);今日与「近 7 天」为固定口径,不随范围变化。'))
334
402
  }
335
403
 
336
404
  // ===== 架构健康提示区(架构自省 L1 · 数据源:state.healthHints)=====
@@ -466,6 +534,19 @@
466
534
  var _a = useState(false), open = _a[0], setOpen = _a[1]
467
535
  var _p = useState(null), pos = _p[0], setPos = _p[1]
468
536
  var ref = useRef(null); var btnRef = useRef(null)
537
+ // 开关点击即时反馈(反馈:勾选几秒才同步,task-muw5uudk):
538
+ // checked 是受控值(读 Props→state,由 3s 轮询通知才更新),此前 onChange 只发 rpc →
539
+ // 点下去要等 set-board-config + fetchTasks 双往返(安静板卡上 tasksHash 不变还会被渲染节约吃掉)才翻面。
540
+ // 口径:**先写 state + notify(点击瞬时翻面)→ 再 rpc('set-board-config') → 失败回滚 state + notify
541
+ // 并复用错误条 reportReadErr(沿用「读路径错误防线」:绝不让开关停在未落盘的值上)**。
542
+ // 成功路径的权威纠偏在 kernel.fetchTasks(配置变化检测),此处不重复 fetchTasks:3s 轮询自会带回真值。
543
+ function cfgRollback(key, prev) { state[key] = prev; notify() }
544
+ function setCfg(key, next) {
545
+ var prev = !!state[key] // 乐观更新前的值,失败回滚用
546
+ state[key] = next === true // 归一成布尔:默认开口径由服务端权威值(fetchTasks)纠偏
547
+ notify() // 立即翻面,不等 rpc
548
+ rpc('set-board-config', { key: key, value: next === true }).catch(function (e) { cfgRollback(key, prev); reportReadErr('设置保存失败:' + readErrText(e)) })
549
+ }
469
550
  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
551
  function toggle() {
471
552
  // position:fixed + 视口坐标:面板容器是 overflow:hidden + maxHeight:60vh,
@@ -494,16 +575,16 @@
494
575
  // 学习飞轮 v1 总开关:关掉后不生成候选教训、prompt 不提软召回、详情页不渲染「沉淀」按钮
495
576
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '学习反馈'),
496
577
  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 () {}) } }),
578
+ React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) { setCfg('feedbackEnabled', e.target.checked) } }),
498
579
  React.createElement('span', null, '候选教训(Verifier 驳回/仲裁结论自动生成候选,Worker prompt 提示先检索历史教训)')),
499
580
  // 回执开关(用户要求「回执可以做一个开关,放到设置里」):派发/完成两类回执各自可关,缺省都开。
500
581
  // 只影响回执播报,不影响派发与状态机;歧义裁决通知不在此闸门内(见下行说明文案)。
501
582
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '通知'),
502
583
  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 () {}) } }),
584
+ React.createElement('input', { type: 'checkbox', checked: props.notifyDispatch !== false, onChange: function (e) { setCfg('notifyDispatch', e.target.checked) } }),
504
585
  React.createElement('span', null, '⚡ 派发回执(任务被 Worker/Verifier 领走时播报)')),
505
586
  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 () {}) } }),
587
+ React.createElement('input', { type: 'checkbox', checked: props.notifyDone !== false, onChange: function (e) { setCfg('notifyDone', e.target.checked) } }),
507
588
  React.createElement('span', null, '✅ 完成回执(任务完成或阻塞时聚合播报)')),
508
589
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '歧义裁决通知不受这两个开关影响(任务等人裁决必须提醒)'),
509
590
  // 史诗拆分总开关(板级 epicSplit,缺省 true):**只关引导,不禁机制**——关掉后 Team 提示词不再
@@ -511,7 +592,7 @@
511
592
  // 子卡、史诗自动收口/hooks 状态机照常(用户/主窗口明确要拆时不受阻)。勾选态缺字段=开,与 host 同口径。
512
593
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '功能'),
513
594
  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 () {}) } }),
595
+ React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { setCfg('epicSplit', e.target.checked) } }),
515
596
  React.createElement('span', null, '🧩 史诗拆分:大任务引导拆为 epic + 子任务')),
516
597
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作')) : null)
517
598
  }
@@ -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 } },