dsh-workplan 1.5.2 → 1.6.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/CHANGELOG.md CHANGED
@@ -1,3 +1,96 @@
1
+ ## 1.6.0 — 2026-10-29
2
+
3
+ **「会话终止」收敛版**。用户截图圈出 `session-7129faea-d4bd-4ed5-a081-769496e07ceb`:
4
+ 这一轮对话早就结束了(没有任何东西在跑),面板红圈的分组仍写着**「进行中」**,卡片还停在
5
+ 「预算用完,可续跑」。本次让**宿主会话生命周期**成为「到底有没有在跑」的唯一事实源,并把
6
+ 该结论落成新的独立阶段 `ended` / 独立分组「已结束」,同时修掉回放路径上把失败写入计为完成的
7
+ 第二个打架点。
8
+
9
+ ### 事故与根因
10
+
11
+ 事故会话的关键事实(来自会话日志回放):
12
+
13
+ 1. 插件**只订阅 `agent/pre-step`**(`src/host.ts:286`),`turn/end`、`session/event`、
14
+ `session/disposed` 一个都不消费 → **会话结束这件事对插件不可见**;
15
+ 2. 计划阶段只由「派发动作」写入(`executing` / `paused` / `blocked` …),派发结束后
16
+ 再没有任何代码把它改回来,于是阶段永久停在被最后一次派发写下的值;
17
+ 3. 唯一存在的兜底只覆盖一种情形:**从未派发**且 `pending` 超过 10 分钟才改判 `cancelled`
18
+ (`src/runtime.ts` 的 `STALE_PENDING_MS` 分支),对「派发过、会话结束」完全无效;
19
+ 4. 客户端把**除 done 以外的一切阶段**都塞进「进行中」组
20
+ (分组条件只有 `planPhaseOf(...) !== 'done'`)→ `paused` / `blocked` / `pending` /
21
+ `executing` / `degraded` 全部显示成「进行中」;
22
+ 5. 回放路径 `reconstructFromSession` 见到 `dsh_workplan_update` 调用就 `applyStatus`,
23
+ **忽略宿主 `tool/result` 的 `message.isError`** → 该会话里一次失败的写入被记成完成
24
+ (面板显示 7/12,真实完成 6/12),刷新前后又一次「数据源打架」。
25
+
26
+ 结论:这是**插件缺陷**,不是上游缺失——宿主侧 `turn/start` / `turn/end`(含
27
+ `aborted.reason.kind` 的 `user` / `parent` / `hook` / `disposed`)事件齐全且已落盘。
28
+
29
+ ### 改动
30
+
31
+ - **`src/runtime.ts`**
32
+ - 新增并导出 `sessionTurnState(session)`:从会话日志末尾向前扫最近 200 条
33
+ (`TURN_BOUNDARY_SCAN`),先遇 `turn/start` ⇒ 这一轮还开着,先遇 `turn/end` ⇒
34
+ 已结束并带出 `reason.kind` 与 `aborted` 子原因;扫不到 ⇒ `unknown`(**绝不猜**)。
35
+ 读日志而不是加事件订阅:日志既是实时事实也是回放事实,实时与刷新后必然同源。
36
+ - `serialisePlan()`(实时与回放共用的唯一出口)在既有的过期 `pending` 兜底之后、
37
+ 不变量自检之前收敛:`planPhase` 不是 `done` / `cancelled` 且这一轮已结束 ⇒
38
+ 阶段改为 `ended`,原阶段写进 `suspendedPhase`,并附带 `endReason` /
39
+ `endReasonDetail` / `endedAt`。**`done` 不参与改写**(否则直接违反不变量 I8)。
40
+ - **`turn/end` 不等于「取消」或「完成」**:一轮结束后可以随时续跑,因此只把
41
+ 「没有东西在跑」这件事表达出来,不动 `record.phase`——用户说「继续」、日志出现
42
+ 新的 `turn/start` 后,下一份载荷自然回到原派发阶段,无反向迁移逻辑。
43
+ - 回放改为**先登记、后定案**:`tool/call` 只记 `callId → 参数`,等配对
44
+ `tool/result` 到达才采纳;`message.isError === true` 丢弃,无结果同样不采纳
45
+ (保守优先于乐观)。跨计划的迟到结果按 `planId` 拒绝,不污染新计划。
46
+ - **`src/client.ts`**
47
+ - 新增阶段 `ended`(中「已结束,可续跑」/ 英「Ended (resumable)」)与**第三个独立分组
48
+ 「已结束」**,位置在「进行中」与「已完成」之间;分组、组头计数、卡片胶囊继续共用
49
+ `planPhaseOf` 单一结论。
50
+ - 新增 `phaseLabel()`:徽标两段式表达「终态 + 原阶段」(如
51
+ 「已结束,可续跑 · 预算用完,可续跑」「已结束,可续跑 · 卡住了」),
52
+ 不再用一个词暗示还在跑,也不丢「停在哪儿」这个关键信息。
53
+ - 「已结束」组保留「需处理」角标(判定用后端下发的 `suspendedPhase`),
54
+ 组头按 `endedAt` 排序(最近结束在前);有进行中/已结束计划时「已完成」组默认收起。
55
+ - 图表口径随之改名:环图为「未结束 / 已完成」,柱图为「未结束计划的完成度」,
56
+ 已结束但未完成的计划一并计入「未结束」。
57
+ - 新增 `.dsh-wp-group--ended` 样式:灰点、**不脉冲**——「没有东西在跑」不允许用动画表达。
58
+ - **版本**:`1.5.2 → 1.6.0`(minor)。载荷新增字段、客户端新增分组,属**向后兼容的对外
59
+ 扩展**而非纯 bugfix:旧客户端读新载荷只会忽略 `ended` 相关字段并把计划当 `pending`,
60
+ 不会崩溃,但**看不到「已结束」**,所以建议同步升级。
61
+
62
+ ### 测试(新增 17 条,全部可复跑)
63
+
64
+ - `tests/session-end-convergence.test.ts`(12 条,后端/载荷级):正常结束、异常中断
65
+ (`aborted/user`)、进程级中断(`aborted/disposed`)、**正在跑不误判**、全部完成仍是
66
+ `done` 且无不变量违规、结束后说「继续」阶段回退、刷新幂等、双会话隔离、
67
+ 扫描上限返回 `unknown`、回放只采纳成功写入(isError / 无结果 / 成功三态)。
68
+ - `tests/ended-group.test.ts`(5 条,渲染级):`ended` 计划只进「已结束」组、组名不再出现
69
+ 「进行中」、徽标两段式、卡住的计划仍报「需处理」、无 `ended` 时不渲染空组、
70
+ 「已完成」组折叠规则。
71
+
72
+ ```
73
+ npm test # 137 tests / 137 pass / 0 fail
74
+ ```
75
+
76
+ ### 验证方式(任何人都可复跑)
77
+
78
+ ```bash
79
+ # 1) 完整回归(含本次新增的 17 条用例)
80
+ cd /Users/open/Documents/Stocks/plugins/dsh-workplan && npm test
81
+ # 2) 只跑本次的两个新用例文件
82
+ node --test dist-tests/tests/session-end-convergence.test.js dist-tests/tests/ended-group.test.js
83
+ # 3) 面板验收(人工):打开一个「已结束、未完成」的会话,
84
+ # 任务进度面板应把它放在「已结束」组,徽标写「已结束,可续跑 · …」;
85
+ # 说一句「继续」后,同一张卡应回到「进行中」。
86
+ ```
87
+
88
+ ### 兼容与升级
89
+
90
+ - 已装 1.5.x 的用户:升级后刷新页面即可,「已结束」组立即生效;
91
+ - 若面板在升级后仍显示「进行中」:先确认宿主版本已把 `turn/end` 落盘(1.6.0 依赖它,
92
+ 未落盘时会保持原判定而不是误判),再确认浏览器已加载新 `client.js`。
93
+
1
94
  ## 1.5.2 — 2026-10-29
2
95
 
3
96
  **「完成语义」统一版**。用户截图圈出一张 `14/14` 全部做完、环形进度 `100%` 的计划卡,
@@ -99,7 +192,36 @@
99
192
 
100
193
  ### 发布
101
194
 
102
- - **未执行**。`git tag v1.5.2`、`npm publish` 属对外发布,需用户明确授权后再做。
195
+ - 两个渠道、同一份制品(2026-10-29 13:22–13:25 +08:00 实测,发布已完成):
196
+ - git:`https://github.com/abbaababa/dsh-workplan.git` 的 annotated tag `v1.5.2` → `666b0fe`;
197
+ - npm:公共 registry `https://registry.npmjs.org/dsh-workplan` 的 `1.5.2`(`dist-tags.latest`)。
198
+ - **状态**:
199
+ - `git push origin main` → `b0e0b76..666b0fe main -> main`(快进,exit 0);
200
+ - `git push origin v1.5.2` → `* [new tag] v1.5.2 -> v1.5.2`(exit 0);
201
+ - 远端复核(`git ls-remote`):`refs/heads/main` = `666b0fee483b…`,
202
+ `refs/tags/v1.5.2` = `155c601c73d7…` → `^{}` = `666b0fee483b…`;
203
+ - `npm publish ./dsh-workplan-1.5.2.tgz --access public --ignore-scripts`
204
+ (在 `git worktree add --detach /tmp/dsh-wp-1.5.2 v1.5.2` 的干净快照上先 `npm pack`,
205
+ 再发布该 `.tgz`,22 个文件全部取自 tag 提交)→ `+ dsh-workplan@1.5.2`(exit 0);
206
+ - `https://registry.npmjs.org/dsh-workplan/1.5.2` → **HTTP 200**,
207
+ `time['1.5.2']` = `2026-10-29T05:24:48.346Z`(13:24:48 +08:00),`dist-tags.latest` = `1.5.2`;
208
+ - 消费者视角:空白目录 `npm i dsh-workplan@1.5.2` →
209
+ `meta = {"name":"dsh-workplan","version":"1.5.2",…}`(22 个文件)。
210
+ - **已发布制品的校验值**(registry 实测,与本地 `.tgz` 逐字节一致):
211
+ - npm `shasum`(SHA-1):`2159024d69c05b3aeeefd5e7ff2f208ec05c67a2`
212
+ - npm `integrity`(SHA-512 SRI):`sha512-sY7UgT09JwfRFe61HKCOCH7wLz0xbdAxyLla3WO8xoyZ/T6Yxb6H/IqRFf9LEX9EsnW7+z4jcHICms6RKPmTzQ==`
213
+ - 本地 `.tgz` SHA-256:`4482f202d098bbaec35051e54440d0d2be6e0099b6d6606e3c582b88e1aee9fa`
214
+ (269 264 字节,解包 888 135 字节,22 个文件)
215
+ - 发布顺序:先分支、后标签、再 npm,避免出现「远端有 tag 而 npm 没有」的半发布态。
216
+ - registry 传播延迟实测约 45 秒(直连 404×3 → 200)。
217
+ - **CI 说明**:tag 提交 `666b0fe` 的 verify 运行 `40823115340` 为 failure——20 个用例在
218
+ Node 22 上被 `cancelledByParent`(`Promise resolution is still pending but the event loop
219
+ has already resolved`),与 1.5.1 / 1.5.0 / 1.4.3 的推送运行失败原因完全相同(各 20 条),
220
+ 属**既有 CI 缺陷**,与本次改动无关,也不影响制品(测试不入包)。根因是运行时把轮次预算/
221
+ 空闲计时器 `unref` 掉后,Node 22 在事件循环清空时取消挂起的测试。已在
222
+ `9f0de34`(只改 `tests/` 两个文件,`git diff v1.5.2 -- . ':!tests' ':!docs' ':!scripts'` 为空)
223
+ 用「测试期间持有一个 ref 句柄、`after` 钩子释放」修掉;对应运行 `40823694198` 全绿。
224
+ - 命令原文与发布后验证:见 `docs/release-record-1.5.2-20261029.md`。
103
225
 
104
226
  ### 回滚
105
227
 
package/README.en.md CHANGED
@@ -53,7 +53,7 @@ dsh plugin --profile web add "$(pwd)"
53
53
  4. Open **Task progress** in the sidebar:
54
54
 
55
55
  - five headline numbers: **tasks / work items / done / in progress / blocked**;
56
- - plan cards grouped into **In progress** and **Completed**, each with a completion rate;
56
+ - plan cards grouped into **In progress**, **Ended** and **Completed**, each with a completion rate. **Ended** means the turn has stopped (nothing is running) while the plan is still unfinished; the card also says where it stopped (budget spent / stuck / not started) and resuming is one sentence away;
57
57
  - expand any plan to see per-stage progress and who owns each work item.
58
58
 
59
59
  Every number is derived from the same task state — the panel can no longer claim "0 in progress" while the list below shows work running. That inconsistency is exactly what 1.4.3 fixed.
package/README.md CHANGED
@@ -57,7 +57,7 @@ dsh plugin --profile web add "$(pwd)"
57
57
  4. 点左侧栏的**「任务进度」**,就能看到:
58
58
 
59
59
  - 顶部五个数字:**几个任务 / 分配了多少件工作 / 做好了 / 正在做 / 卡住了**;
60
- - 计划卡:分「进行中」「已完成」两组,右上角是完成率;
60
+ - 计划卡:分「进行中」「已结束」「已完成」三组,右上角是完成率。「已结束」= 这一轮已经停了(没有东西在跑)但计划还没做完,卡片会写清停在哪儿(预算用完 / 卡住了 / 还没开始),说一句「继续」即可接着推进;
61
61
  - 展开任意计划:每个阶段的进度、每件具体工作由哪个部门负责、当前状态。
62
62
 
63
63
  5. 进度数字背后的每一行,都来自同一份任务状态——**不会再出现「显示 0 个正在做、但下面明明写着正在做」这种自相矛盾**(1.4.3 专门修的就是这个,见下文)。
package/client.js CHANGED
@@ -19,21 +19,26 @@ window.__ModuleLoader__.load({
19
19
  "panel": "任务进度",
20
20
  "groupLive": "进行中",
21
21
  "groupDone": "已完成",
22
+ "groupEnded": "已结束",
22
23
  "groupLiveHint": "还没结束,正在或即将推进",
23
24
  "groupDoneHint": "已全部完成并收尾",
25
+ "groupEndedHint": "这一轮已经停了,没有东西在跑;说「续跑」可继续推进",
24
26
  "groupCollapse": "收起",
25
27
  "groupExpand": "展开",
26
28
  "groupNeeds": "需处理",
27
29
  "groupLiveEmpty": "没有进行中的任务",
28
30
  "groupDoneEmpty": "还没有完成的任务",
31
+ "groupEndedEmpty": "没有已结束但未完成的任务",
29
32
  "groupSummary": "未完成 {open} / 已完成 {done}(完成率 {pct}%)",
30
33
  "groupSummaryNoRate": "未完成 {open} / 已完成 {done}(完成率暂无数据)",
31
34
  "groupAria": "{title}组,{plans} 个计划,共 {total} 件具体工作,已完成 {done} 件,完成率 {pct}%。其中正在做 {active} 件、卡住 {blocked} 件、还没开始 {pending} 件。",
32
35
  "groupAriaEmpty": "{title}组,{plans} 个计划,暂无具体工作项。",
33
36
  "groupPlansLabel": "个计划",
34
- "chartGroups": "两类计划数量占比",
35
- "chartLive": "进行中计划完成度(按工作项)",
36
- "chartMore": "另有 {n} 个进行中计划未画入图表",
37
+ "chartGroups": "计划数量与完成情况",
38
+ "chartOpen": "未结束",
39
+ "chartDone": "已完成",
40
+ "chartLive": "未完成计划的完成度(按工作项)",
41
+ "chartMore": "另有 {n} 个未完成计划未画入图表",
37
42
  "title": "任务进度:一眼看懂",
38
43
  "subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
39
44
  "helpTitle": "先看这三句",
@@ -67,7 +72,8 @@ window.__ModuleLoader__.load({
67
72
  "blocked": "卡住了",
68
73
  "paused": "预算用完,可续跑",
69
74
  "degraded": "安排失败",
70
- "cancelled": "已取消"
75
+ "cancelled": "已取消",
76
+ "ended": "已结束,可续跑"
71
77
  },
72
78
  "status": {
73
79
  "pending": "还没开始",
@@ -183,6 +189,7 @@ window.__ModuleLoader__.load({
183
189
  "en": {
184
190
  "groupLive": "In progress",
185
191
  "groupDone": "Completed",
192
+ "groupEnded": "Ended",
186
193
  "groupLiveHint": "Not finished yet — running or about to run",
187
194
  "groupDoneHint": "All work finished and wrapped up",
188
195
  "groupCollapse": "Collapse",
@@ -190,14 +197,17 @@ window.__ModuleLoader__.load({
190
197
  "groupNeeds": "needs attention",
191
198
  "groupLiveEmpty": "No work in progress",
192
199
  "groupDoneEmpty": "Nothing completed yet",
200
+ "groupEndedEmpty": "No ended-but-unfinished plans",
193
201
  "groupSummary": "Open {open} / done {done} ({pct}% complete)",
194
202
  "groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
195
203
  "groupAria": "{title} group: {plans} plan(s), {total} work item(s), {done} done, {pct}% complete. Right now {active} in progress, {blocked} blocked, {pending} not started.",
196
204
  "groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
197
205
  "groupPlansLabel": "plan(s)",
198
- "chartGroups": "Plan count by group",
199
- "chartLive": "In-progress plans by work item",
200
- "chartMore": "{n} more in-progress plans not drawn",
206
+ "chartGroups": "Plan count and completion",
207
+ "chartOpen": "Open",
208
+ "chartDone": "Completed",
209
+ "chartLive": "Open plans by work item",
210
+ "chartMore": "{n} more open plans not drawn",
201
211
  "panel": "Task progress",
202
212
  "title": "Task progress at a glance",
203
213
  "subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
@@ -232,7 +242,8 @@ window.__ModuleLoader__.load({
232
242
  "blocked": "Stuck",
233
243
  "paused": "Paused (budget spent)",
234
244
  "degraded": "Planning failed",
235
- "cancelled": "Cancelled"
245
+ "cancelled": "Cancelled",
246
+ "ended": "Ended (resumable)"
236
247
  },
237
248
  "status": {
238
249
  "pending": "Not started",
@@ -402,6 +413,7 @@ window.__ModuleLoader__.load({
402
413
  .dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
403
414
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
404
415
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
416
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #94a3b8, #64748b); }
405
417
  /* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
406
418
  .dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
407
419
  .dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
@@ -428,6 +440,8 @@ window.__ModuleLoader__.load({
428
440
  .dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
429
441
  .dsh-wp-group--live .dsh-wp-group-dot { background: #f97316; box-shadow: 0 0 0 4px rgba(249,115,22,.16); animation: dsh-wp-pulse 1.8s ease-in-out infinite; }
430
442
  .dsh-wp-group--done .dsh-wp-group-dot { background: #0ea5e9; box-shadow: 0 0 0 4px rgba(14,165,233,.14); }
443
+ /* [1.6.0] 已结束:灰点、不脉冲——"没有东西在跑"这件事不允许用动画表达。 */
444
+ .dsh-wp-group--ended .dsh-wp-group-dot { background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
431
445
  .dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
432
446
  /* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
433
447
  15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
@@ -436,6 +450,7 @@ window.__ModuleLoader__.load({
436
450
  .dsh-wp-group-count { min-width: 22px; padding: 0 7px; height: 20px; border-radius: 999px; font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
437
451
  .dsh-wp-group--live .dsh-wp-group-count { background: rgba(249,115,22,.14); box-shadow: inset 0 0 0 1px rgba(249,115,22,.34); }
438
452
  .dsh-wp-group--done .dsh-wp-group-count { background: rgba(14,165,233,.14); box-shadow: inset 0 0 0 1px rgba(14,165,233,.34); }
453
+ .dsh-wp-group--ended .dsh-wp-group-count { background: rgba(148,163,184,.16); box-shadow: inset 0 0 0 1px rgba(148,163,184,.36); }
439
454
  /* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
440
455
  让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
441
456
  .dsh-wp-group-alert { font-size: 12px; font-weight: 650; color: var(--dsw-alias-state-error-primary, #ef4444); color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 80%, var(--dsw-alias-label-primary, #0f172a) 20%); background: rgba(239,68,68,.12); padding: 1px 8px; border-radius: 999px; }
@@ -748,6 +763,7 @@ window.__ModuleLoader__.load({
748
763
  .dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
749
764
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
750
765
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
766
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #cbd5e1, #94a3b8); }
751
767
  .dsh-wp-group--done { opacity: .97; }
752
768
  .dsh-wp-group-head--btn { transition: background-color .16s ease; }
753
769
  .dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
@@ -980,6 +996,12 @@ window.__ModuleLoader__.load({
980
996
  function phaseTone(phase) {
981
997
  if (phase === 'blocked')
982
998
  return 'blocked';
999
+ /*
1000
+ * [1.6.0] `ended` = 这一轮停了,但计划没做完。它既不是成功(done),也不能
1001
+ * 冒充正在干活(active);用中性 idle 色,卡在哪由二级徽标 suspendedPhase 说明。
1002
+ */
1003
+ if (phase === 'ended')
1004
+ return 'idle';
983
1005
  if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused')
984
1006
  return 'warn';
985
1007
  if (phase === 'integrating' || phase === 'done')
@@ -988,6 +1010,23 @@ window.__ModuleLoader__.load({
988
1010
  return 'active';
989
1011
  return 'idle';
990
1012
  }
1013
+ /*
1014
+ * [1.6.0] 「已结束」徽标要把「停在哪儿」一起说出来。
1015
+ *
1016
+ * 只说「已结束,可续跑」,用户不知道是预算用完、卡住了、还是压根没开始;
1017
+ * 只说原阶段(「正在做」),又会像事故截图那样暗示还在跑。两段式表达:
1018
+ * 终态 + 原阶段,一次说清两个事实。
1019
+ */
1020
+ function phaseLabel(phase, session, t) {
1021
+ const base = t(`phase.${phase}`);
1022
+ if (phase !== 'ended')
1023
+ return base;
1024
+ const suspended = String(session?.suspendedPhase ?? '');
1025
+ if (!suspended)
1026
+ return base;
1027
+ const detail = t(`phase.${suspended}`);
1028
+ return detail && detail !== base ? `${base} · ${detail}` : base;
1029
+ }
991
1030
  function allEntries(session) {
992
1031
  const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item) => [item.subtaskId, item]));
993
1032
  const entries = [];
@@ -1637,7 +1676,7 @@ window.__ModuleLoader__.load({
1637
1676
  panelClassName: 'dsh-wp-session-body',
1638
1677
  title: session.title || t('panel'),
1639
1678
  meta: h(PlanViz, { t, counts, now, rows: stageRows }),
1640
- badge: h(Pill, { tone }, t(`phase.${phase}`)),
1679
+ badge: h(Pill, { tone }, phaseLabel(phase, session, t)),
1641
1680
  }, session.updatedAt
1642
1681
  ? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
1643
1682
  : null, open === true ? h(PlanOverview, { t, stats, counts, rows: stageRows }) : null, session.degraded ? h('div', { className: 'dsh-wp-banner dsh-wp-banner--degraded' }, h('strong', null, t('degraded')), h('div', null, plainError(session.error || '')), h('div', { className: 'dsh-wp-muted' }, t('degradedHint'))) : null, h(Guide, { session, t }), departments.length ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('who')), h('div', { className: 'dsh-wp-departments' }, departments.map((dept) => h(Pill, { key: dept.id, tone: dept.blocked > 0 ? 'blocked' : dept.done === dept.total ? 'done' : 'active' }, `${deptName(t, dept.id)} ${dept.done}/${dept.total} ${t('stats.done')}`)))) : null, h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('steps')), (session.stages || []).map((stage, index) => h(StageCard, {
@@ -1722,7 +1761,7 @@ window.__ModuleLoader__.load({
1722
1761
  charts.push(new lib(canvasA, {
1723
1762
  type: 'doughnut',
1724
1763
  data: {
1725
- labels: [t('groupLive'), t('groupDone')],
1764
+ labels: [t('chartOpen'), t('chartDone')],
1726
1765
  datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
1727
1766
  },
1728
1767
  options: {
@@ -1783,8 +1822,8 @@ window.__ModuleLoader__.load({
1783
1822
  // 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
1784
1823
  // 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
1785
1824
  // fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
1786
- h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` })), chartState !== 'ready'
1787
- ? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` }, h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }), h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } })), h('div', { className: 'dsh-wp-fallback-legend' }, h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('groupLive')} ${liveCount}`), h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('groupDone')} ${doneCount}`)))
1825
+ h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` })), chartState !== 'ready'
1826
+ ? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` }, h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }), h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } })), h('div', { className: 'dsh-wp-fallback-legend' }, h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('chartOpen')} ${liveCount}`), h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('chartDone')} ${doneCount}`)))
1788
1827
  : null), (liveBars || []).length
1789
1828
  ? h('div', { className: 'dsh-wp-chart-card' }, h('div', { className: 'dsh-wp-chart-title' }, t('chartLive')), h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'live', role: 'img', 'aria-label': t('chartLive') })), hiddenBars > 0 ? h('div', { className: 'dsh-wp-chart-more' }, fill(t('chartMore'), { n: hiddenBars })) : null)
1790
1829
  : null);
@@ -1883,11 +1922,18 @@ window.__ModuleLoader__.load({
1883
1922
  : null), h('span', { className: 'dsh-wp-group-ring-label' }, value === null ? '—' : `${value}%`));
1884
1923
  }
1885
1924
  function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }) {
1925
+ /*
1926
+ * [1.6.0] 三个分组共用一套组件:live(真的在跑)、ended(这一轮停了但没做完)、
1927
+ * done(全部完成)。`ended` 是本次事故的修复核心——它必须是一个**独立分组**,
1928
+ * 而不是被塞回「进行中」:会话已经结束的事实不允许用「进行中」这三个字表达。
1929
+ */
1886
1930
  const isDone = group === 'done';
1931
+ const isEnded = group === 'ended';
1932
+ const keySuffix = isDone ? 'Done' : isEnded ? 'Ended' : 'Live';
1887
1933
  // 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
1888
1934
  // 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
1889
- // 折叠态由本组件自己持有(不给 users 存偏好),刷新后回到默认,符合 §7.3「不打扰」。
1890
- const [open, setOpen] = useState(!(isDone && hasLive));
1935
+ // [1.6.0] 「已结束」组同理:还有东西在跑时收起来,只留组头一行结论。
1936
+ const [open, setOpen] = useState(!((isDone || isEnded) && hasLive));
1891
1937
  // §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
1892
1938
  // ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
1893
1939
  // 依赖都是布尔值,轮询刷新不会重复触发。
@@ -1896,7 +1942,7 @@ window.__ModuleLoader__.load({
1896
1942
  setOpen(true); }, [hasLive]);
1897
1943
  useEffect(() => { if (holdsOpenCard)
1898
1944
  setOpen(true); }, [holdsOpenCard]);
1899
- const collapsible = isDone && list.length > 0;
1945
+ const collapsible = (isDone || isEnded) && list.length > 0;
1900
1946
  // 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
1901
1947
  // React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
1902
1948
  // 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
@@ -1907,8 +1953,8 @@ window.__ModuleLoader__.load({
1907
1953
  // T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
1908
1954
  // 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
1909
1955
  const totals = useMemo(() => groupTotals(list), [list]);
1910
- const groupTitle = t(isDone ? 'groupDone' : 'groupLive');
1911
- const hintText = t(isDone ? 'groupDoneHint' : 'groupLiveHint');
1956
+ const groupTitle = t(`group${keySuffix}`);
1957
+ const hintText = t(`group${keySuffix}Hint`);
1912
1958
  // 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
1913
1959
  // 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
1914
1960
  // 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
@@ -1973,7 +2019,7 @@ window.__ModuleLoader__.load({
1973
2019
  open: openSessionId === session.sessionId,
1974
2020
  onToggle: (next) => setOpenSessionId(next ? session.sessionId : null),
1975
2021
  })))
1976
- : h('div', { className: 'dsh-wp-group-empty' }, t(isDone ? 'groupDoneEmpty' : 'groupLiveEmpty')));
2022
+ : h('div', { className: 'dsh-wp-group-empty' }, t(`group${keySuffix}Empty`)));
1977
2023
  }
1978
2024
  function Donut({ totals, t }) {
1979
2025
  const total = Math.max(0, Number(totals?.total) || 0);
@@ -2036,7 +2082,7 @@ window.__ModuleLoader__.load({
2036
2082
  'aria-label': `${t('jumpToTask')} ${session.title || t('panel')}`,
2037
2083
  onClick: () => { if (typeof onSelect === 'function')
2038
2084
  onSelect(session.sessionId); },
2039
- }, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, t(`phase.${phase}`)), h('span', null, `${percent}%`)));
2085
+ }, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, phaseLabel(phase, session, t)), h('span', null, `${percent}%`)));
2040
2086
  }));
2041
2087
  }
2042
2088
  function Dashboard({ t }) {
@@ -2084,6 +2130,7 @@ window.__ModuleLoader__.load({
2084
2130
  }, [activeSessions]);
2085
2131
  const groups = useMemo(() => {
2086
2132
  const live = [];
2133
+ const ended = [];
2087
2134
  const finished = [];
2088
2135
  const urgentPhases = ['blocked', 'degraded', 'paused'];
2089
2136
  /*
@@ -2093,10 +2140,17 @@ window.__ModuleLoader__.load({
2093
2140
  * 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
2094
2141
  * 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
2095
2142
  * `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
2143
+ *
2144
+ * [1.6.0] 新增 ended 分组:宿主已经 `turn/end`(会话这一轮结束、没有任何
2145
+ * 东西在跑),而计划还没做完的,一律进「已结束」,绝不进「进行中」。
2146
+ * 这正是本次事故的现象:会话结束 4 小时后仍显示「进行中」。
2096
2147
  */
2097
2148
  for (const session of activeSessions) {
2098
- if (planPhaseOf(session.phase, planCounts(session.stats)) === 'done')
2149
+ const phase = planPhaseOf(session.phase, planCounts(session.stats));
2150
+ if (phase === 'done')
2099
2151
  finished.push(session);
2152
+ else if (phase === 'ended')
2153
+ ended.push(session);
2100
2154
  else
2101
2155
  live.push(session);
2102
2156
  }
@@ -2114,11 +2168,19 @@ window.__ModuleLoader__.load({
2114
2168
  integrating: 3,
2115
2169
  planning: 4,
2116
2170
  pending: 5,
2171
+ ended: 6,
2117
2172
  };
2118
2173
  const rank = (session) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5;
2119
2174
  live.sort((a, b) => (rank(a) - rank(b))
2120
2175
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
2121
2176
  || String(a.sessionId).localeCompare(String(b.sessionId)));
2177
+ /*
2178
+ * [1.6.0] 已结束组按「最近结束在前」:`endedAt` 是宿主 turn/end 的时间戳
2179
+ * (后端下发),比 updatedAt 更准确地表达「什么时候停的」。
2180
+ */
2181
+ const endedAt = (session) => Number(session.endedAt) || Number(session.updatedAt) || Number(session.createdAt) || 0;
2182
+ ended.sort((a, b) => (endedAt(b) - endedAt(a))
2183
+ || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
2122
2184
  /*
2123
2185
  * 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
2124
2186
  * statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
@@ -2144,9 +2206,20 @@ window.__ModuleLoader__.load({
2144
2206
  };
2145
2207
  finished.sort((a, b) => (finishedAt(b) - finishedAt(a))
2146
2208
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
2147
- return { live, finished, urgent: live.filter((session) => urgentPhases.includes(session.phase)).length };
2209
+ return {
2210
+ live,
2211
+ ended,
2212
+ finished,
2213
+ urgent: live.filter((session) => urgentPhases.includes(session.phase)).length,
2214
+ /*
2215
+ * [1.6.0] 「已结束」组也需要「需处理」角标:卡住的计划停了仍然是卡住的,
2216
+ * 判定用后端下发的 suspendedPhase(原派发阶段),而不是被收敛后的 ended。
2217
+ */
2218
+ urgentEnded: ended.filter((session) => urgentPhases.includes(String(session.suspendedPhase ?? ''))).length,
2219
+ };
2148
2220
  }, [activeSessions]);
2149
- const liveBars = useMemo(() => groups.live.slice(0, 6).map((session) => {
2221
+ // [1.6.0] 图表口径 = 未结束计划(进行中 + 已结束未完成),与环图标签一致。
2222
+ const liveBars = useMemo(() => [...groups.live, ...groups.ended].slice(0, 6).map((session) => {
2150
2223
  const raw = String(session.title || t('panel'));
2151
2224
  return {
2152
2225
  label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
@@ -2156,14 +2229,23 @@ window.__ModuleLoader__.load({
2156
2229
  }), [groups, t]);
2157
2230
  return h('div', { className: 'dsh-wp' }, h('header', { className: 'dsh-wp-header' }, h('div', null, h('h1', { className: 'dsh-wp-title' }, t('title')), h('p', { className: 'dsh-wp-subtitle' }, t('subtitle')), h('div', { className: 'dsh-wp-meta' }, updatedAt ? h('span', null, `${t('updatedAt')} ${formatTime(updatedAt)}`) : null, h('span', null, `${t('scopeAll')}:${activeSessions.length} ${t('sessionCount')},${t('taskBreakdown')} ${totals.total} ${t('subtaskCount')}`))), h('button', { type: 'button', className: 'dsh-wp-button', onClick: reload, disabled: loading }, t('refresh'))), error ? h(ErrorBanner, { message: error, onRetry: reload, t }) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-statbar', role: 'list' }, h('div', { className: 'dsh-wp-stat dsh-wp-stat--tasks', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, activeSessions.length), h('div', { className: 'dsh-wp-stat-label' }, t('sessionCount'))), h('div', { className: 'dsh-wp-stat', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.total), h('div', { className: 'dsh-wp-stat-label' }, t('workItems'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--done', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.done), h('div', { className: 'dsh-wp-stat-label' }, t('stats.done'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--active', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.inProgress), h('div', { className: 'dsh-wp-stat-label' }, t('stats.inProgress'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--blocked', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.blocked), h('div', { className: 'dsh-wp-stat-label' }, t('stats.blocked')))) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-scope-note' }, t('allPlansHint')) : null, activeSessions.length === 0
2158
2231
  ? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
2159
- : h('div', { className: 'dsh-wp-groups' }, h(SessionGroup, { group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }), h(SessionGroup, { group: 'done', list: groups.finished, hasLive: groups.live.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
2232
+ : h('div', { className: 'dsh-wp-groups' },
2233
+ /*
2234
+ * [1.6.0] 三组顺序:真的在跑 → 已结束(停在半路) → 已完成。
2235
+ * 「已结束」夹在中间是有意为之:它是用户最需要立刻知道的那一档
2236
+ * (刚停、还没做完),不能滑到历史组的下面去。
2237
+ */
2238
+ h(SessionGroup, { key: 'live', group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }), groups.ended.length > 0
2239
+ ? h(SessionGroup, { key: 'ended', group: 'ended', list: groups.ended, hasLive: groups.live.length > 0, urgent: groups.urgentEnded, t, openSessionId, setOpenSessionId })
2240
+ : null, h(SessionGroup, { key: 'done', group: 'done', list: groups.finished, hasLive: groups.live.length > 0 || groups.ended.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
2160
2241
  // 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
2161
2242
  // 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
2162
2243
  // 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
2163
2244
  activeSessions.length > 0 ? h('div', { className: 'dsh-wp-overview' }, h(Donut, { totals, t }), h(TaskBars, { sessions: activeSessions, t, onSelect: selectSession })) : null,
2164
2245
  // 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
2165
2246
  // (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
2166
- h(GroupCharts, { liveCount: groups.live.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length - 6), t })));
2247
+ // [1.6.0] 「未结束」= 进行中 + 已结束但未完成:两者都还没做完,都是用户要盯的对象。
2248
+ h(GroupCharts, { liveCount: groups.live.length + groups.ended.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length + groups.ended.length - 6), t })));
2167
2249
  }
2168
2250
  function TurnCard({ sessionId, turn, t }) {
2169
2251
  const { sessions } = usePlans(sessionId);
@@ -2188,7 +2270,7 @@ window.__ModuleLoader__.load({
2188
2270
  const stats = session.stats || {};
2189
2271
  const phase = planPhaseOf(session.phase || 'pending', planCounts(stats));
2190
2272
  const tone = phaseTone(phase);
2191
- return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, t(`phase.${phase}`))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
2273
+ return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, phaseLabel(phase, session, t))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
2192
2274
  }
2193
2275
  function contentText(block) {
2194
2276
  if (!block || !Array.isArray(block.content))
package/lib/host.js CHANGED
@@ -294,7 +294,7 @@ export function apply(ctx, config = {}) {
294
294
  }
295
295
  export const meta = {
296
296
  name,
297
- version: '1.5.2',
297
+ version: '1.6.0',
298
298
  promptOrder: PROMPT_ORDER,
299
299
  routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
300
300
  };
package/lib/runtime.js CHANGED
@@ -842,6 +842,59 @@ export async function dispatchPlan(ctx, record, options) {
842
842
  }
843
843
  return record;
844
844
  }
845
+ /*
846
+ * [1.6.0] 会话「这一轮到底还在不在跑」的唯一事实源。
847
+ *
848
+ * 事故(session-7129faea…):会话早已结束,面板仍把计划列在「进行中」组,
849
+ * 卡片写着「预算用完,可续跑」——没有任何东西在跑,状态却表示正在跑。
850
+ *
851
+ * 根因:插件只订阅 `agent/pre-step`,`turn/end`、`session/disposed` 全都收不到;
852
+ * 计划状态因此只由「派发结果」驱动,而派发结束后不再有任何事件把它收敛。
853
+ *
854
+ * 修复不引入第二个事件订阅(跨 scope 的事件投递受 scope 过滤,不可靠;而且
855
+ * 订阅只覆盖实时路径,刷新/重启后全靠回放,两条链路一旦不同源就会来回翻转)。
856
+ * 这里直接读会话日志本身——它既是实时事实,也是回放事实:
857
+ *
858
+ * - 从末尾向前找最近的一条「轮次边界」事件:先遇到 `turn/end` ⇒ 这一轮已结束;
859
+ * 先遇到 `turn/start` ⇒ 这一轮还开着;都没找到 ⇒ 未知,调用方保持原判定。
860
+ * - 不存在「会话已结束」这种事件:宿主只有每轮 `turn/end`。因此**不能**把
861
+ * `turn/end` 直接等同于「计划取消/完成」——一轮结束后可以随时续跑,这也是
862
+ * 卡片提供「可续跑」的原因。它只证明「此刻没有东西在跑」。
863
+ * - 向后扫描设上限,避免越界;超过上限就返回 unknown,绝不猜测。
864
+ */
865
+ const TURN_BOUNDARY_SCAN = 200;
866
+ export function sessionTurnState(session) {
867
+ const total = Number(session?.seq);
868
+ if (!Number.isFinite(total) || total <= 0 || typeof session?.eventAt !== 'function') {
869
+ return { state: 'unknown', reason: null, reasonDetail: null, at: null };
870
+ }
871
+ const floor = Math.max(0, Math.floor(total) - TURN_BOUNDARY_SCAN);
872
+ for (let seq = Math.floor(total) - 1; seq >= floor; seq -= 1) {
873
+ let event;
874
+ try {
875
+ event = session.eventAt(seq);
876
+ }
877
+ catch {
878
+ return { state: 'unknown', reason: null, reasonDetail: null, at: null };
879
+ }
880
+ if (!event)
881
+ continue;
882
+ if (event.type === 'turn/start')
883
+ return { state: 'open', reason: null, reasonDetail: null, at: null };
884
+ if (event.type === 'turn/end') {
885
+ const reason = event.data?.reason ?? null;
886
+ const detail = reason && reason.kind === 'aborted' ? (reason.reason?.kind ?? null) : null;
887
+ const at = Number(event.time);
888
+ return {
889
+ state: 'ended',
890
+ reason: typeof reason?.kind === 'string' ? reason.kind : 'completed',
891
+ reasonDetail: typeof detail === 'string' ? detail : null,
892
+ at: Number.isFinite(at) ? at : null,
893
+ };
894
+ }
895
+ }
896
+ return { state: 'unknown', reason: null, reasonDetail: null, at: null };
897
+ }
845
898
  function serialisePlan(record, session) {
846
899
  const plan = record.plan ?? null;
847
900
  /*
@@ -872,6 +925,36 @@ function serialisePlan(record, session) {
872
925
  if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
873
926
  phase = 'cancelled';
874
927
  }
928
+ /*
929
+ * [1.6.0] 终态收敛:这一轮已经结束,而计划没有完成 ⇒ 不许再显示「进行中」。
930
+ *
931
+ * `phase` 到这里为止只由「派发动作」写入(executing / paused / blocked …),
932
+ * 而派发结束后没有任何事件把它改回来,于是会话结束、进程空闲、面板却仍在
933
+ * 「进行中」组里(事故截图)。上面 `sessionTurnState` 用会话日志回答
934
+ * 「此刻还有没有东西在跑」,这里把答案落成独立阶段 `ended`:
935
+ *
936
+ * - done / cancelled 已经是终态,不再改写(done 改写会直接违反不变量 I8);
937
+ * - 其余阶段一律收敛为 `ended`,并把原阶段留在 `suspendedPhase` 里,
938
+ * 卡片因此仍能显示「预算用完,可续跑」「卡住了」这类二级事实,不丢信息;
939
+ * - 只改**下发出去的那一份**,不动 `record.phase`:用户说「继续」后同一轮
940
+ * 重新开始(日志里出现新的 turn/start),下一份 payload 自然回到原来的
941
+ * 派发状态,不需要任何反向迁移逻辑;
942
+ * - 实时路径与回放路径都经过本函数,因此刷新前后结论必然一致(T4 硬约束)。
943
+ */
944
+ let suspendedPhase = null;
945
+ let endReason = null;
946
+ let endReasonDetail = null;
947
+ let endedAt = null;
948
+ if (phase !== 'done' && phase !== 'cancelled') {
949
+ const turn = sessionTurnState(session);
950
+ if (turn.state === 'ended') {
951
+ suspendedPhase = phase;
952
+ endReason = turn.reason;
953
+ endReasonDetail = turn.reasonDetail;
954
+ endedAt = turn.at;
955
+ phase = 'ended';
956
+ }
957
+ }
875
958
  /*
876
959
  * 每次真正下发前跑一遍跨字段不变量(I1–I10)。正常情况下恒为空;一旦某个
877
960
  * 未来改动重新引入「进度与状态打架」,这里会把它连同具体字段值一起带出去,
@@ -883,6 +966,7 @@ function serialisePlan(record, session) {
883
966
  planId: record.planId ?? plan?.planId ?? null,
884
967
  title: plan?.title ?? record.title ?? '任务进度',
885
968
  phase,
969
+ ...(phase === 'ended' ? { suspendedPhase, endReason, endReasonDetail, endedAt } : {}),
886
970
  ...(violations.length ? { violations } : {}),
887
971
  degraded: record.degraded === true,
888
972
  error: record.error ? truncate(record.error, 2000) : null,
@@ -950,6 +1034,8 @@ export function reconstructFromSession(session, targetPlanId) {
950
1034
  * plan and let the caller ask for a specific one.
951
1035
  */
952
1036
  const finished = [];
1037
+ /** [1.6.0] callId → 待确认的 update 写入;只有配对的 tool/result 才能定案。 */
1038
+ const pendingToolCalls = new Map();
953
1039
  const snapshot = () => ({ plan, statuses, resultPhase, resultSeen, resumeCount, cancelled });
954
1040
  for (let seq = start; seq < session.seq; seq += 1) {
955
1041
  let event;
@@ -1002,20 +1088,47 @@ export function reconstructFromSession(session, targetPlanId) {
1002
1088
  }
1003
1089
  }
1004
1090
  else if (event.type === 'tool/call' && plan) {
1091
+ /*
1092
+ * [1.6.0] 只登记,不下结论。
1093
+ *
1094
+ * 回放原来一见到 `dsh_workplan_update` 的调用就 applyStatus,但调用发起不等于
1095
+ * 状态写入成功:宿主的 tool/result 带着 `message.isError`。会话
1096
+ * session-7129faea 里就有一次失败的 update 被回放记成完成,于是刷新后面板显示
1097
+ * 7/12,而真实只完成 6/12 —— 回放比实时“更乐观”,这是第二个数据源打架点。
1098
+ * 现在一律等对应的 tool/result:成功才采纳,失败或没有结果都不采纳。
1099
+ */
1005
1100
  const data = event.data;
1006
- if (data?.name === 'dsh_workplan_update') {
1101
+ if (data?.name === 'dsh_workplan_update' && typeof data.callId === 'string') {
1007
1102
  const args = readToolArguments(data.arguments);
1008
1103
  if (args && typeof args.subtaskId === 'string' && typeof args.status === 'string') {
1009
- try {
1010
- statuses = applyStatus(statuses, args.subtaskId, {
1011
- status: args.status,
1012
- progress: typeof args.progress === 'number' ? args.progress : undefined,
1013
- summary: typeof args.note === 'string' ? args.note : undefined,
1014
- });
1015
- }
1016
- catch {
1017
- // An invalid historical call is ignored; the live cache remains authoritative.
1018
- }
1104
+ pendingToolCalls.set(data.callId, { args, planId: plan.planId });
1105
+ }
1106
+ }
1107
+ }
1108
+ else if (event.type === 'tool/result') {
1109
+ const message = event.data?.message;
1110
+ const callId = message?.role === 'tool' ? message.toolCallId : null;
1111
+ if (typeof callId !== 'string')
1112
+ continue;
1113
+ const pending = pendingToolCalls.get(callId);
1114
+ pendingToolCalls.delete(callId);
1115
+ if (!pending || !plan || pending.planId !== plan.planId) {
1116
+ // 结果晚于新计划到达:旧计划的写入不污染新计划的状态表。
1117
+ }
1118
+ else if (message.isError === true) {
1119
+ // 失败的更新不是完成:状态表保持原样。
1120
+ }
1121
+ else {
1122
+ const args = pending.args;
1123
+ try {
1124
+ statuses = applyStatus(statuses, args.subtaskId, {
1125
+ status: args.status,
1126
+ progress: typeof args.progress === 'number' ? args.progress : undefined,
1127
+ summary: typeof args.note === 'string' ? args.note : undefined,
1128
+ });
1129
+ }
1130
+ catch {
1131
+ // An invalid historical call is ignored; the live cache remains authoritative.
1019
1132
  }
1020
1133
  }
1021
1134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workplan",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Automatic task decomposition, real department subagents, and a responsive workplan dashboard for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/client.ts CHANGED
@@ -20,21 +20,26 @@ window.__ModuleLoader__.load({
20
20
  "panel": "任务进度",
21
21
  "groupLive": "进行中",
22
22
  "groupDone": "已完成",
23
+ "groupEnded": "已结束",
23
24
  "groupLiveHint": "还没结束,正在或即将推进",
24
25
  "groupDoneHint": "已全部完成并收尾",
26
+ "groupEndedHint": "这一轮已经停了,没有东西在跑;说「续跑」可继续推进",
25
27
  "groupCollapse": "收起",
26
28
  "groupExpand": "展开",
27
29
  "groupNeeds": "需处理",
28
30
  "groupLiveEmpty": "没有进行中的任务",
29
31
  "groupDoneEmpty": "还没有完成的任务",
32
+ "groupEndedEmpty": "没有已结束但未完成的任务",
30
33
  "groupSummary": "未完成 {open} / 已完成 {done}(完成率 {pct}%)",
31
34
  "groupSummaryNoRate": "未完成 {open} / 已完成 {done}(完成率暂无数据)",
32
35
  "groupAria": "{title}组,{plans} 个计划,共 {total} 件具体工作,已完成 {done} 件,完成率 {pct}%。其中正在做 {active} 件、卡住 {blocked} 件、还没开始 {pending} 件。",
33
36
  "groupAriaEmpty": "{title}组,{plans} 个计划,暂无具体工作项。",
34
37
  "groupPlansLabel": "个计划",
35
- "chartGroups": "两类计划数量占比",
36
- "chartLive": "进行中计划完成度(按工作项)",
37
- "chartMore": "另有 {n} 个进行中计划未画入图表",
38
+ "chartGroups": "计划数量与完成情况",
39
+ "chartOpen": "未结束",
40
+ "chartDone": "已完成",
41
+ "chartLive": "未完成计划的完成度(按工作项)",
42
+ "chartMore": "另有 {n} 个未完成计划未画入图表",
38
43
 
39
44
  "title": "任务进度:一眼看懂",
40
45
  "subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
@@ -69,7 +74,8 @@ window.__ModuleLoader__.load({
69
74
  "blocked": "卡住了",
70
75
  "paused": "预算用完,可续跑",
71
76
  "degraded": "安排失败",
72
- "cancelled": "已取消"
77
+ "cancelled": "已取消",
78
+ "ended": "已结束,可续跑"
73
79
  },
74
80
  "status": {
75
81
  "pending": "还没开始",
@@ -185,6 +191,7 @@ window.__ModuleLoader__.load({
185
191
  "en": {
186
192
  "groupLive": "In progress",
187
193
  "groupDone": "Completed",
194
+ "groupEnded": "Ended",
188
195
  "groupLiveHint": "Not finished yet — running or about to run",
189
196
  "groupDoneHint": "All work finished and wrapped up",
190
197
  "groupCollapse": "Collapse",
@@ -192,14 +199,17 @@ window.__ModuleLoader__.load({
192
199
  "groupNeeds": "needs attention",
193
200
  "groupLiveEmpty": "No work in progress",
194
201
  "groupDoneEmpty": "Nothing completed yet",
202
+ "groupEndedEmpty": "No ended-but-unfinished plans",
195
203
  "groupSummary": "Open {open} / done {done} ({pct}% complete)",
196
204
  "groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
197
205
  "groupAria": "{title} group: {plans} plan(s), {total} work item(s), {done} done, {pct}% complete. Right now {active} in progress, {blocked} blocked, {pending} not started.",
198
206
  "groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
199
207
  "groupPlansLabel": "plan(s)",
200
- "chartGroups": "Plan count by group",
201
- "chartLive": "In-progress plans by work item",
202
- "chartMore": "{n} more in-progress plans not drawn",
208
+ "chartGroups": "Plan count and completion",
209
+ "chartOpen": "Open",
210
+ "chartDone": "Completed",
211
+ "chartLive": "Open plans by work item",
212
+ "chartMore": "{n} more open plans not drawn",
203
213
  "panel": "Task progress",
204
214
  "title": "Task progress at a glance",
205
215
  "subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
@@ -234,7 +244,8 @@ window.__ModuleLoader__.load({
234
244
  "blocked": "Stuck",
235
245
  "paused": "Paused (budget spent)",
236
246
  "degraded": "Planning failed",
237
- "cancelled": "Cancelled"
247
+ "cancelled": "Cancelled",
248
+ "ended": "Ended (resumable)"
238
249
  },
239
250
  "status": {
240
251
  "pending": "Not started",
@@ -405,6 +416,7 @@ window.__ModuleLoader__.load({
405
416
  .dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
406
417
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
407
418
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
419
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #94a3b8, #64748b); }
408
420
  /* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
409
421
  .dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
410
422
  .dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
@@ -431,6 +443,8 @@ window.__ModuleLoader__.load({
431
443
  .dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
432
444
  .dsh-wp-group--live .dsh-wp-group-dot { background: #f97316; box-shadow: 0 0 0 4px rgba(249,115,22,.16); animation: dsh-wp-pulse 1.8s ease-in-out infinite; }
433
445
  .dsh-wp-group--done .dsh-wp-group-dot { background: #0ea5e9; box-shadow: 0 0 0 4px rgba(14,165,233,.14); }
446
+ /* [1.6.0] 已结束:灰点、不脉冲——"没有东西在跑"这件事不允许用动画表达。 */
447
+ .dsh-wp-group--ended .dsh-wp-group-dot { background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
434
448
  .dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
435
449
  /* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
436
450
  15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
@@ -439,6 +453,7 @@ window.__ModuleLoader__.load({
439
453
  .dsh-wp-group-count { min-width: 22px; padding: 0 7px; height: 20px; border-radius: 999px; font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
440
454
  .dsh-wp-group--live .dsh-wp-group-count { background: rgba(249,115,22,.14); box-shadow: inset 0 0 0 1px rgba(249,115,22,.34); }
441
455
  .dsh-wp-group--done .dsh-wp-group-count { background: rgba(14,165,233,.14); box-shadow: inset 0 0 0 1px rgba(14,165,233,.34); }
456
+ .dsh-wp-group--ended .dsh-wp-group-count { background: rgba(148,163,184,.16); box-shadow: inset 0 0 0 1px rgba(148,163,184,.36); }
442
457
  /* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
443
458
  让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
444
459
  .dsh-wp-group-alert { font-size: 12px; font-weight: 650; color: var(--dsw-alias-state-error-primary, #ef4444); color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 80%, var(--dsw-alias-label-primary, #0f172a) 20%); background: rgba(239,68,68,.12); padding: 1px 8px; border-radius: 999px; }
@@ -751,6 +766,7 @@ window.__ModuleLoader__.load({
751
766
  .dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
752
767
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
753
768
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
769
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #cbd5e1, #94a3b8); }
754
770
  .dsh-wp-group--done { opacity: .97; }
755
771
  .dsh-wp-group-head--btn { transition: background-color .16s ease; }
756
772
  .dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
@@ -982,12 +998,33 @@ window.__ModuleLoader__.load({
982
998
 
983
999
  function phaseTone(phase: any) {
984
1000
  if (phase === 'blocked') return 'blocked'
1001
+ /*
1002
+ * [1.6.0] `ended` = 这一轮停了,但计划没做完。它既不是成功(done),也不能
1003
+ * 冒充正在干活(active);用中性 idle 色,卡在哪由二级徽标 suspendedPhase 说明。
1004
+ */
1005
+ if (phase === 'ended') return 'idle'
985
1006
  if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused') return 'warn'
986
1007
  if (phase === 'integrating' || phase === 'done') return 'done'
987
1008
  if (phase === 'executing' || phase === 'planning') return 'active'
988
1009
  return 'idle'
989
1010
  }
990
1011
 
1012
+ /*
1013
+ * [1.6.0] 「已结束」徽标要把「停在哪儿」一起说出来。
1014
+ *
1015
+ * 只说「已结束,可续跑」,用户不知道是预算用完、卡住了、还是压根没开始;
1016
+ * 只说原阶段(「正在做」),又会像事故截图那样暗示还在跑。两段式表达:
1017
+ * 终态 + 原阶段,一次说清两个事实。
1018
+ */
1019
+ function phaseLabel(phase: any, session: any, t: any) {
1020
+ const base = t(`phase.${phase}`)
1021
+ if (phase !== 'ended') return base
1022
+ const suspended = String(session?.suspendedPhase ?? '')
1023
+ if (!suspended) return base
1024
+ const detail = t(`phase.${suspended}`)
1025
+ return detail && detail !== base ? `${base} · ${detail}` : base
1026
+ }
1027
+
991
1028
  function allEntries(session: any) {
992
1029
  const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item: any) => [item.subtaskId, item]))
993
1030
  const entries: any[] = []
@@ -1863,7 +1900,7 @@ window.__ModuleLoader__.load({
1863
1900
  panelClassName: 'dsh-wp-session-body',
1864
1901
  title: session.title || t('panel'),
1865
1902
  meta: h(PlanViz, { t, counts, now, rows: stageRows }),
1866
- badge: h(Pill, { tone }, t(`phase.${phase}`)),
1903
+ badge: h(Pill, { tone }, phaseLabel(phase, session, t)),
1867
1904
  },
1868
1905
  session.updatedAt
1869
1906
  ? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
@@ -1967,7 +2004,7 @@ window.__ModuleLoader__.load({
1967
2004
  charts.push(new lib(canvasA, {
1968
2005
  type: 'doughnut',
1969
2006
  data: {
1970
- labels: [t('groupLive'), t('groupDone')],
2007
+ labels: [t('chartOpen'), t('chartDone')],
1971
2008
  datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
1972
2009
  },
1973
2010
  options: {
@@ -2022,16 +2059,16 @@ window.__ModuleLoader__.load({
2022
2059
  // 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
2023
2060
  // 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
2024
2061
  // fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
2025
- h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` })),
2062
+ h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` })),
2026
2063
  chartState !== 'ready'
2027
2064
  ? h('div', { className: 'dsh-wp-fallback' },
2028
- h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` },
2065
+ h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` },
2029
2066
  h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }),
2030
2067
  h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } }),
2031
2068
  ),
2032
2069
  h('div', { className: 'dsh-wp-fallback-legend' },
2033
- h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('groupLive')} ${liveCount}`),
2034
- h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('groupDone')} ${doneCount}`),
2070
+ h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('chartOpen')} ${liveCount}`),
2071
+ h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('chartDone')} ${doneCount}`),
2035
2072
  ),
2036
2073
  )
2037
2074
  : null,
@@ -2148,18 +2185,25 @@ window.__ModuleLoader__.load({
2148
2185
  }
2149
2186
 
2150
2187
  function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }: any) {
2188
+ /*
2189
+ * [1.6.0] 三个分组共用一套组件:live(真的在跑)、ended(这一轮停了但没做完)、
2190
+ * done(全部完成)。`ended` 是本次事故的修复核心——它必须是一个**独立分组**,
2191
+ * 而不是被塞回「进行中」:会话已经结束的事实不允许用「进行中」这三个字表达。
2192
+ */
2151
2193
  const isDone = group === 'done'
2194
+ const isEnded = group === 'ended'
2195
+ const keySuffix = isDone ? 'Done' : isEnded ? 'Ended' : 'Live'
2152
2196
  // 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
2153
2197
  // 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
2154
- // 折叠态由本组件自己持有(不给 users 存偏好),刷新后回到默认,符合 §7.3「不打扰」。
2155
- const [open, setOpen] = useState(!(isDone && hasLive))
2198
+ // [1.6.0] 「已结束」组同理:还有东西在跑时收起来,只留组头一行结论。
2199
+ const [open, setOpen] = useState(!((isDone || isEnded) && hasLive))
2156
2200
  // §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
2157
2201
  // ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
2158
2202
  // 依赖都是布尔值,轮询刷新不会重复触发。
2159
2203
  const holdsOpenCard = list.some((session: any) => session.sessionId === openSessionId)
2160
2204
  useEffect(() => { if (!hasLive) setOpen(true) }, [hasLive])
2161
2205
  useEffect(() => { if (holdsOpenCard) setOpen(true) }, [holdsOpenCard])
2162
- const collapsible = isDone && list.length > 0
2206
+ const collapsible = (isDone || isEnded) && list.length > 0
2163
2207
  // 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
2164
2208
  // React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
2165
2209
  // 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
@@ -2170,8 +2214,8 @@ window.__ModuleLoader__.load({
2170
2214
  // T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
2171
2215
  // 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
2172
2216
  const totals = useMemo(() => groupTotals(list), [list])
2173
- const groupTitle = t(isDone ? 'groupDone' : 'groupLive')
2174
- const hintText = t(isDone ? 'groupDoneHint' : 'groupLiveHint')
2217
+ const groupTitle = t(`group${keySuffix}`)
2218
+ const hintText = t(`group${keySuffix}Hint`)
2175
2219
  // 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
2176
2220
  // 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
2177
2221
  // 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
@@ -2238,7 +2282,7 @@ window.__ModuleLoader__.load({
2238
2282
  open: openSessionId === session.sessionId,
2239
2283
  onToggle: (next: boolean) => setOpenSessionId(next ? session.sessionId : null),
2240
2284
  })))
2241
- : h('div', { className: 'dsh-wp-group-empty' }, t(isDone ? 'groupDoneEmpty' : 'groupLiveEmpty')),
2285
+ : h('div', { className: 'dsh-wp-group-empty' }, t(`group${keySuffix}Empty`)),
2242
2286
  )
2243
2287
  }
2244
2288
 
@@ -2331,7 +2375,7 @@ window.__ModuleLoader__.load({
2331
2375
  h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } }),
2332
2376
  ),
2333
2377
  h('div', { className: 'dsh-wp-bar-meta' },
2334
- h('span', null, t(`phase.${phase}`)),
2378
+ h('span', null, phaseLabel(phase, session, t)),
2335
2379
  h('span', null, `${percent}%`),
2336
2380
  ),
2337
2381
  )
@@ -2383,6 +2427,7 @@ window.__ModuleLoader__.load({
2383
2427
  }, [activeSessions])
2384
2428
  const groups = useMemo(() => {
2385
2429
  const live: any[] = []
2430
+ const ended: any[] = []
2386
2431
  const finished: any[] = []
2387
2432
  const urgentPhases = ['blocked', 'degraded', 'paused']
2388
2433
  /*
@@ -2392,9 +2437,15 @@ window.__ModuleLoader__.load({
2392
2437
  * 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
2393
2438
  * 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
2394
2439
  * `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
2440
+ *
2441
+ * [1.6.0] 新增 ended 分组:宿主已经 `turn/end`(会话这一轮结束、没有任何
2442
+ * 东西在跑),而计划还没做完的,一律进「已结束」,绝不进「进行中」。
2443
+ * 这正是本次事故的现象:会话结束 4 小时后仍显示「进行中」。
2395
2444
  */
2396
2445
  for (const session of activeSessions) {
2397
- if (planPhaseOf(session.phase, planCounts(session.stats)) === 'done') finished.push(session)
2446
+ const phase = planPhaseOf(session.phase, planCounts(session.stats))
2447
+ if (phase === 'done') finished.push(session)
2448
+ else if (phase === 'ended') ended.push(session)
2398
2449
  else live.push(session)
2399
2450
  }
2400
2451
  /*
@@ -2411,11 +2462,19 @@ window.__ModuleLoader__.load({
2411
2462
  integrating: 3,
2412
2463
  planning: 4,
2413
2464
  pending: 5,
2465
+ ended: 6,
2414
2466
  }
2415
2467
  const rank = (session: any) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5
2416
2468
  live.sort((a: any, b: any) => (rank(a) - rank(b))
2417
2469
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
2418
2470
  || String(a.sessionId).localeCompare(String(b.sessionId)))
2471
+ /*
2472
+ * [1.6.0] 已结束组按「最近结束在前」:`endedAt` 是宿主 turn/end 的时间戳
2473
+ * (后端下发),比 updatedAt 更准确地表达「什么时候停的」。
2474
+ */
2475
+ const endedAt = (session: any) => Number(session.endedAt) || Number(session.updatedAt) || Number(session.createdAt) || 0
2476
+ ended.sort((a: any, b: any) => (endedAt(b) - endedAt(a))
2477
+ || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)))
2419
2478
  /*
2420
2479
  * 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
2421
2480
  * statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
@@ -2438,9 +2497,20 @@ window.__ModuleLoader__.load({
2438
2497
  }
2439
2498
  finished.sort((a: any, b: any) => (finishedAt(b) - finishedAt(a))
2440
2499
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)))
2441
- return { live, finished, urgent: live.filter((session: any) => urgentPhases.includes(session.phase)).length }
2500
+ return {
2501
+ live,
2502
+ ended,
2503
+ finished,
2504
+ urgent: live.filter((session: any) => urgentPhases.includes(session.phase)).length,
2505
+ /*
2506
+ * [1.6.0] 「已结束」组也需要「需处理」角标:卡住的计划停了仍然是卡住的,
2507
+ * 判定用后端下发的 suspendedPhase(原派发阶段),而不是被收敛后的 ended。
2508
+ */
2509
+ urgentEnded: ended.filter((session: any) => urgentPhases.includes(String(session.suspendedPhase ?? ''))).length,
2510
+ }
2442
2511
  }, [activeSessions])
2443
- const liveBars = useMemo(() => groups.live.slice(0, 6).map((session: any) => {
2512
+ // [1.6.0] 图表口径 = 未结束计划(进行中 + 已结束未完成),与环图标签一致。
2513
+ const liveBars = useMemo(() => [...groups.live, ...groups.ended].slice(0, 6).map((session: any) => {
2444
2514
  const raw = String(session.title || t('panel'))
2445
2515
  return {
2446
2516
  label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
@@ -2472,8 +2542,16 @@ window.__ModuleLoader__.load({
2472
2542
  activeSessions.length === 0
2473
2543
  ? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
2474
2544
  : h('div', { className: 'dsh-wp-groups' },
2475
- h(SessionGroup, { group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }),
2476
- h(SessionGroup, { group: 'done', list: groups.finished, hasLive: groups.live.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
2545
+ /*
2546
+ * [1.6.0] 三组顺序:真的在跑 → 已结束(停在半路) → 已完成。
2547
+ * 「已结束」夹在中间是有意为之:它是用户最需要立刻知道的那一档
2548
+ * (刚停、还没做完),不能滑到历史组的下面去。
2549
+ */
2550
+ h(SessionGroup, { key: 'live', group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }),
2551
+ groups.ended.length > 0
2552
+ ? h(SessionGroup, { key: 'ended', group: 'ended', list: groups.ended, hasLive: groups.live.length > 0, urgent: groups.urgentEnded, t, openSessionId, setOpenSessionId })
2553
+ : null,
2554
+ h(SessionGroup, { key: 'done', group: 'done', list: groups.finished, hasLive: groups.live.length > 0 || groups.ended.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
2477
2555
  // 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
2478
2556
  // 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
2479
2557
  // 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
@@ -2483,7 +2561,8 @@ window.__ModuleLoader__.load({
2483
2561
  ) : null,
2484
2562
  // 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
2485
2563
  // (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
2486
- h(GroupCharts, { liveCount: groups.live.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length - 6), t }),
2564
+ // [1.6.0] 「未结束」= 进行中 + 已结束但未完成:两者都还没做完,都是用户要盯的对象。
2565
+ h(GroupCharts, { liveCount: groups.live.length + groups.ended.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length + groups.ended.length - 6), t }),
2487
2566
  ),
2488
2567
  )
2489
2568
  }
@@ -2511,7 +2590,7 @@ window.__ModuleLoader__.load({
2511
2590
  return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` },
2512
2591
  h('div', { className: 'dsh-wp-turn-title' },
2513
2592
  h('span', null, session.title || t('panel')),
2514
- h(Pill, { tone }, t(`phase.${phase}`)),
2593
+ h(Pill, { tone }, phaseLabel(phase, session, t)),
2515
2594
  ),
2516
2595
  h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } },
2517
2596
  `${t('helpWho')}:${workerText(session, t)}`),
package/src/host.ts CHANGED
@@ -325,7 +325,7 @@ export function apply(ctx: any, config: WorkplanConfig = {}) {
325
325
 
326
326
  export const meta = {
327
327
  name,
328
- version: '1.5.2',
328
+ version: '1.6.0',
329
329
  promptOrder: PROMPT_ORDER,
330
330
  routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
331
331
  }
package/src/runtime.ts CHANGED
@@ -856,6 +856,58 @@ export async function dispatchPlan(ctx: any, record: any, options: any) {
856
856
  return record
857
857
  }
858
858
 
859
+ /*
860
+ * [1.6.0] 会话「这一轮到底还在不在跑」的唯一事实源。
861
+ *
862
+ * 事故(session-7129faea…):会话早已结束,面板仍把计划列在「进行中」组,
863
+ * 卡片写着「预算用完,可续跑」——没有任何东西在跑,状态却表示正在跑。
864
+ *
865
+ * 根因:插件只订阅 `agent/pre-step`,`turn/end`、`session/disposed` 全都收不到;
866
+ * 计划状态因此只由「派发结果」驱动,而派发结束后不再有任何事件把它收敛。
867
+ *
868
+ * 修复不引入第二个事件订阅(跨 scope 的事件投递受 scope 过滤,不可靠;而且
869
+ * 订阅只覆盖实时路径,刷新/重启后全靠回放,两条链路一旦不同源就会来回翻转)。
870
+ * 这里直接读会话日志本身——它既是实时事实,也是回放事实:
871
+ *
872
+ * - 从末尾向前找最近的一条「轮次边界」事件:先遇到 `turn/end` ⇒ 这一轮已结束;
873
+ * 先遇到 `turn/start` ⇒ 这一轮还开着;都没找到 ⇒ 未知,调用方保持原判定。
874
+ * - 不存在「会话已结束」这种事件:宿主只有每轮 `turn/end`。因此**不能**把
875
+ * `turn/end` 直接等同于「计划取消/完成」——一轮结束后可以随时续跑,这也是
876
+ * 卡片提供「可续跑」的原因。它只证明「此刻没有东西在跑」。
877
+ * - 向后扫描设上限,避免越界;超过上限就返回 unknown,绝不猜测。
878
+ */
879
+ const TURN_BOUNDARY_SCAN = 200
880
+
881
+ export function sessionTurnState(session: any) {
882
+ const total = Number(session?.seq)
883
+ if (!Number.isFinite(total) || total <= 0 || typeof session?.eventAt !== 'function') {
884
+ return { state: 'unknown', reason: null, reasonDetail: null, at: null }
885
+ }
886
+ const floor = Math.max(0, Math.floor(total) - TURN_BOUNDARY_SCAN)
887
+ for (let seq = Math.floor(total) - 1; seq >= floor; seq -= 1) {
888
+ let event
889
+ try {
890
+ event = session.eventAt(seq)
891
+ } catch {
892
+ return { state: 'unknown', reason: null, reasonDetail: null, at: null }
893
+ }
894
+ if (!event) continue
895
+ if (event.type === 'turn/start') return { state: 'open', reason: null, reasonDetail: null, at: null }
896
+ if (event.type === 'turn/end') {
897
+ const reason = event.data?.reason ?? null
898
+ const detail = reason && reason.kind === 'aborted' ? (reason.reason?.kind ?? null) : null
899
+ const at = Number(event.time)
900
+ return {
901
+ state: 'ended',
902
+ reason: typeof reason?.kind === 'string' ? reason.kind : 'completed',
903
+ reasonDetail: typeof detail === 'string' ? detail : null,
904
+ at: Number.isFinite(at) ? at : null,
905
+ }
906
+ }
907
+ }
908
+ return { state: 'unknown', reason: null, reasonDetail: null, at: null }
909
+ }
910
+
859
911
  function serialisePlan(record: any, session: any) {
860
912
  const plan = record.plan ?? null
861
913
  /*
@@ -888,6 +940,36 @@ function serialisePlan(record: any, session: any) {
888
940
  if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
889
941
  phase = 'cancelled'
890
942
  }
943
+ /*
944
+ * [1.6.0] 终态收敛:这一轮已经结束,而计划没有完成 ⇒ 不许再显示「进行中」。
945
+ *
946
+ * `phase` 到这里为止只由「派发动作」写入(executing / paused / blocked …),
947
+ * 而派发结束后没有任何事件把它改回来,于是会话结束、进程空闲、面板却仍在
948
+ * 「进行中」组里(事故截图)。上面 `sessionTurnState` 用会话日志回答
949
+ * 「此刻还有没有东西在跑」,这里把答案落成独立阶段 `ended`:
950
+ *
951
+ * - done / cancelled 已经是终态,不再改写(done 改写会直接违反不变量 I8);
952
+ * - 其余阶段一律收敛为 `ended`,并把原阶段留在 `suspendedPhase` 里,
953
+ * 卡片因此仍能显示「预算用完,可续跑」「卡住了」这类二级事实,不丢信息;
954
+ * - 只改**下发出去的那一份**,不动 `record.phase`:用户说「继续」后同一轮
955
+ * 重新开始(日志里出现新的 turn/start),下一份 payload 自然回到原来的
956
+ * 派发状态,不需要任何反向迁移逻辑;
957
+ * - 实时路径与回放路径都经过本函数,因此刷新前后结论必然一致(T4 硬约束)。
958
+ */
959
+ let suspendedPhase: any = null
960
+ let endReason: any = null
961
+ let endReasonDetail: any = null
962
+ let endedAt: any = null
963
+ if (phase !== 'done' && phase !== 'cancelled') {
964
+ const turn = sessionTurnState(session)
965
+ if (turn.state === 'ended') {
966
+ suspendedPhase = phase
967
+ endReason = turn.reason
968
+ endReasonDetail = turn.reasonDetail
969
+ endedAt = turn.at
970
+ phase = 'ended'
971
+ }
972
+ }
891
973
  /*
892
974
  * 每次真正下发前跑一遍跨字段不变量(I1–I10)。正常情况下恒为空;一旦某个
893
975
  * 未来改动重新引入「进度与状态打架」,这里会把它连同具体字段值一起带出去,
@@ -899,6 +981,7 @@ function serialisePlan(record: any, session: any) {
899
981
  planId: record.planId ?? plan?.planId ?? null,
900
982
  title: plan?.title ?? record.title ?? '任务进度',
901
983
  phase,
984
+ ...(phase === 'ended' ? { suspendedPhase, endReason, endReasonDetail, endedAt } : {}),
902
985
  ...(violations.length ? { violations } : {}),
903
986
  degraded: record.degraded === true,
904
987
  error: record.error ? truncate(record.error, 2000) : null,
@@ -966,6 +1049,8 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
966
1049
  * plan and let the caller ask for a specific one.
967
1050
  */
968
1051
  const finished: any[] = []
1052
+ /** [1.6.0] callId → 待确认的 update 写入;只有配对的 tool/result 才能定案。 */
1053
+ const pendingToolCalls = new Map<string, any>()
969
1054
  const snapshot = () => ({ plan, statuses, resultPhase, resultSeen, resumeCount, cancelled })
970
1055
  for (let seq = start; seq < session.seq; seq += 1) {
971
1056
  let event
@@ -1013,19 +1098,42 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
1013
1098
  cancelled = true
1014
1099
  }
1015
1100
  } else if (event.type === 'tool/call' && plan) {
1101
+ /*
1102
+ * [1.6.0] 只登记,不下结论。
1103
+ *
1104
+ * 回放原来一见到 `dsh_workplan_update` 的调用就 applyStatus,但调用发起不等于
1105
+ * 状态写入成功:宿主的 tool/result 带着 `message.isError`。会话
1106
+ * session-7129faea 里就有一次失败的 update 被回放记成完成,于是刷新后面板显示
1107
+ * 7/12,而真实只完成 6/12 —— 回放比实时“更乐观”,这是第二个数据源打架点。
1108
+ * 现在一律等对应的 tool/result:成功才采纳,失败或没有结果都不采纳。
1109
+ */
1016
1110
  const data = event.data
1017
- if (data?.name === 'dsh_workplan_update') {
1111
+ if (data?.name === 'dsh_workplan_update' && typeof data.callId === 'string') {
1018
1112
  const args = readToolArguments(data.arguments)
1019
1113
  if (args && typeof args.subtaskId === 'string' && typeof args.status === 'string') {
1020
- try {
1021
- statuses = applyStatus(statuses, args.subtaskId, {
1022
- status: args.status,
1023
- progress: typeof args.progress === 'number' ? args.progress : undefined,
1024
- summary: typeof args.note === 'string' ? args.note : undefined,
1025
- })
1026
- } catch {
1027
- // An invalid historical call is ignored; the live cache remains authoritative.
1028
- }
1114
+ pendingToolCalls.set(data.callId, { args, planId: plan.planId })
1115
+ }
1116
+ }
1117
+ } else if (event.type === 'tool/result') {
1118
+ const message = event.data?.message
1119
+ const callId = message?.role === 'tool' ? message.toolCallId : null
1120
+ if (typeof callId !== 'string') continue
1121
+ const pending = pendingToolCalls.get(callId)
1122
+ pendingToolCalls.delete(callId)
1123
+ if (!pending || !plan || pending.planId !== plan.planId) {
1124
+ // 结果晚于新计划到达:旧计划的写入不污染新计划的状态表。
1125
+ } else if (message.isError === true) {
1126
+ // 失败的更新不是完成:状态表保持原样。
1127
+ } else {
1128
+ const args = pending.args
1129
+ try {
1130
+ statuses = applyStatus(statuses, args.subtaskId, {
1131
+ status: args.status,
1132
+ progress: typeof args.progress === 'number' ? args.progress : undefined,
1133
+ summary: typeof args.note === 'string' ? args.note : undefined,
1134
+ })
1135
+ } catch {
1136
+ // An invalid historical call is ignored; the live cache remains authoritative.
1029
1137
  }
1030
1138
  }
1031
1139
  }