dsh-activity-pane 0.10.0 → 0.11.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.
@@ -138,11 +138,11 @@ function normalizeDensity(raw) {
138
138
  return raw === "compact" || raw === "medium" || raw === "full" ? raw : "medium";
139
139
  }
140
140
 
141
- /** 显示档位的循环次序:完整 → 中间 → 紧凑完整(R-01-021/AC-01)。 */
142
- const DENSITY_ORDER = ["full", "medium", "compact"];
141
+ /** 显示档位的循环次序:紧凑 → 中间 → 完整紧凑——信息量自小到大(R-01-021/AC-01)。 */
142
+ const DENSITY_ORDER = ["compact", "medium", "full"];
143
143
 
144
144
  /**
145
- * 返回循环切换后的下一显示档位:完整 → 中间 → 紧凑完整;
145
+ * 返回循环切换后的下一显示档位:紧凑 → 中间 → 完整紧凑;
146
146
  * 输入先经 normalizeDensity 归一,非法值视作中间档(R-01-021/AC-01)。
147
147
  */
148
148
  function nextDensity(value) {
@@ -1553,8 +1553,13 @@ function mainTitle(byId, id) {
1553
1553
  * 始终为当帧原始后代活性(供进度锚点记账判定耗尽),不受 delegatingIds 影响。
1554
1554
  * archivedIds(工作区服务的注册表全局归档集合):归档会话及其有效子代理子树不产出活动条目,
1555
1555
  * 普通 fork 仅因共享来源 parentId 不受影响;避免会话服务仍保留旧行或完成提醒时在窗格中滞留。
1556
+ * waitingStarts(进入阻塞等待的时刻,R-01-001/AC-07):Map id → 毫秒时刻,取宿主回合记账的
1557
+ * `openWaitStart`(approval/asked 与 ask_user_question tool/call 边界);阻塞等待发生时本回合
1558
+ * 尚未结束、`lastTurnEnd` 仍是上一回合的旧时刻,不能作为等待进行中会话的排序键(T-141);
1559
+ * 非 Map、缺失记录或值非有限数字(含 null/空串等 Number 归一为 0 的形状)均视为无数据,
1560
+ * 回落宿主列表时间。
1556
1561
  */
1557
- function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = []) {
1562
+ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = [], waitingStarts = null) {
1558
1563
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1559
1564
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1560
1565
  const current = snapshot?.current ?? null;
@@ -1607,16 +1612,28 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1607
1612
  meta.set(id, { row, running, pending, isSub, show, done, err, descendantActive, delegating, depth: 0 });
1608
1613
  }
1609
1614
 
1610
- // 主会话分两组排序(R-01-001/AC-07):运行中主会话置顶,组内按最后一次用户指令
1611
- // 时间(宿主列表时间)从新到旧;等待/完成组(阻塞等待、完成提醒、错误提醒)排后,
1612
- // 组内按进入该状态的时刻(最近一次回合结束登记时刻,缺失回落宿主列表时间)从新到旧。
1613
- // 两组相同时间均回落宿主列表出现顺序。工作区顺序不参与排序,仅承载卡片徽标与名称。
1615
+ // 主会话分两组排序(R-01-001/AC-07,键口径契约详见上方 JSDoc):运行中主会话置顶、
1616
+ // 组内按宿主列表时间从新到旧;等待/完成组排后、组内按进入状态时刻从新到旧;
1617
+ // 两组相同时间均回落宿主列表出现顺序,工作区顺序不参与排序。
1614
1618
  const isRunningEntry = (id) => {
1615
1619
  const m = meta.get(id);
1616
1620
  return m !== undefined && !m.pending && (m.running || m.delegating);
1617
1621
  };
1622
+ const waitingStartTime = (id) => {
1623
+ if (!(waitingStarts instanceof Map)) return null;
1624
+ const value = waitingStarts.get(String(id));
1625
+ // 仅接受真实数字时刻:Number(null)/Number("") 均为 0,会把「无数据」误判为
1626
+ // epoch 最旧时刻(与 normalizeBusyMs 的空值防护同理),一律回落宿主列表时间。
1627
+ if (typeof value !== "number") return null;
1628
+ return Number.isFinite(value) ? value : null;
1629
+ };
1618
1630
  const sortTime = (id) => {
1619
1631
  if (isRunningEntry(id)) return instructionTime(byId[id]);
1632
+ const m = meta.get(id);
1633
+ if (m !== undefined && m.pending) {
1634
+ const start = waitingStartTime(id);
1635
+ return start ?? instructionTime(byId[id]);
1636
+ }
1620
1637
  const record = completionFor(id, completions);
1621
1638
  const end = isRecord(record) ? Number(record.lastTurnEnd) : NaN;
1622
1639
  return Number.isFinite(end) ? end : instructionTime(byId[id]);
@@ -2236,6 +2253,32 @@ function reconcileTurnStats(current, records, { closeOpenTurn = false, forceFres
2236
2253
  return { ...state, watermarkSeq, watermarkTime };
2237
2254
  }
2238
2255
 
2256
+ /**
2257
+ * 会话级登记串行化(R-01-020/AC-02):宿主存储域的 `KvTable.put` 先异步落盘、落盘完成
2258
+ * 后才更新内存快照,因此同一会话的两个边界事件 handler 并发执行时,后到事件会在先到
2259
+ * 事件落盘前读到旧状态,其效果被当作「无效果」永久丢弃(实测:`ask_user_question` 的
2260
+ * `tool/call` 与 11ms 后的 `tool/result` 交错,等待区间悬挂到 `turn/end` 强制结算,
2261
+ * 总耗时系统性缺记整段运行过程)。返回 `(id, run) => tail`:同一会话前一个 run 完成
2262
+ * (含其 put 的内存生效)后才执行下一个 run,不同会话互不阻塞。run 内部须自行捕获
2263
+ * 错误、不得让拒绝外泄(调用方可安全丢弃返回的 tail);tail 供调用方等待与测试断言。
2264
+ * 链空闲即清理,不随会话数增长。
2265
+ */
2266
+ function createSessionEventSerializer() {
2267
+ const tails = new Map();
2268
+ return (id, run) => {
2269
+ const prev = tails.get(id) ?? Promise.resolve();
2270
+ const tail = prev.then(run, run);
2271
+ tails.set(id, tail);
2272
+ tail.then(
2273
+ () => {
2274
+ if (tails.get(id) === tail) tails.delete(id);
2275
+ },
2276
+ () => {},
2277
+ );
2278
+ return tail;
2279
+ };
2280
+ }
2281
+
2239
2282
  /**
2240
2283
  * 构建历史区条目:当前非活动的**主会话**,按最后活动时间从新到旧返回完整候选集合。
2241
2284
  * 子代理是临时工作单元,不入历史区;归档、空会话、完成/错误提醒与委托周期中的会话
@@ -2664,8 +2707,9 @@ const CSS = `
2664
2707
  font-weight: 700;
2665
2708
  letter-spacing: 0.02em;
2666
2709
  }
2667
- /* 标题行拆为两部分:左侧标题区(flex:1 占满剩余宽度)整体即收起控件,悬停/聚焦
2668
- 高亮只覆盖标题区(R-01-011/AC-03、AC-07、R-01-008/AC-02);右侧工具区独立,
2710
+ /* 标题行拆为三部分:最左仓库入口独立区(T-144,与工具区按钮分处标题行两端)、左侧标题区
2711
+ (flex:1 占满剩余宽度)整体即收起控件,悬停/聚焦高亮只覆盖标题区
2712
+ (R-01-011/AC-03、AC-07、R-01-008/AC-02);右侧工具区独立,
2669
2713
  不参与标题区的悬停高亮与折叠激活。 */
2670
2714
  [data-dsh-activity-pane] .dap-titlebar {
2671
2715
  flex: 1;
@@ -2804,7 +2848,11 @@ const CSS = `
2804
2848
  [data-dsh-activity-pane] .dap-density:focus-visible {
2805
2849
  background: #262932;
2806
2850
  }
2807
- /* 仓库入口无描边(T-139):视觉强度弱于带描边的档位切换按钮,仅以不透明底色圆形呈现。 */
2851
+ /* 仓库入口移至标题行最左独立区(T-144,R-01-022/AC-01):与右侧工具区的档位切换按钮
2852
+ 分处标题行两端以消除触屏误触;无描边(T-139),视觉强度弱于带描边的档位切换按钮,仅以不透明
2853
+ 底色圆形呈现。原依赖 .dap-tools 的 padding 由自身 margin 承担(圆形底色盒不可用
2854
+ padding 扩容);左右 margin 各 12px——右缘与标题区的接缝间距对齐工具区的接缝间距
2855
+ (tools padding-left 12px),标题区悬停高亮两侧留白对称(T-146)。 */
2808
2856
  [data-dsh-activity-pane] .dap-repo {
2809
2857
  display: flex;
2810
2858
  align-items: center;
@@ -2816,6 +2864,7 @@ const CSS = `
2816
2864
  color: inherit;
2817
2865
  cursor: pointer;
2818
2866
  text-decoration: none;
2867
+ margin: 0 12px;
2819
2868
  }
2820
2869
  [data-dsh-activity-pane] .dap-repo:hover,
2821
2870
  [data-dsh-activity-pane] .dap-repo:focus-visible {
@@ -3523,8 +3572,8 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3523
3572
  }
3524
3573
  /* 移动端抽屉透明遮罩:抽屉打开时铺满视口、点击收起抽屉(R-01-008/AC-03)。
3525
3574
  完全透明不占布局;z-index 介于主会话与抽屉(2147482990)之间;抽屉打开期间
3526
- 浮动开关隐藏(见 .dap-toggle[data-drawer-open],R-01-008/AC-05),关闭由抽屉
3527
- 头部 × 与遮罩承担;桌面断点外由媒体查询保持隐藏。 */
3575
+ 浮动开关隐藏(见 .dap-toggle[data-drawer-open],R-01-008/AC-05),关闭由
3576
+ 标题行激活(无独立 × 关闭按钮,T-137)与遮罩承担;桌面断点外由媒体查询保持隐藏。 */
3528
3577
  .dap-backdrop {
3529
3578
  position: fixed; inset: 0;
3530
3579
  z-index: 2147482989;
@@ -3671,7 +3720,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {
3671
3720
  body:not([data-ds-dark-theme]) .dap-toggle {
3672
3721
  background: var(--dsw-alias-button-floating-fill, rgba(255, 255, 255, 0.94));
3673
3722
  }
3674
- /* 「回到顶部」与标题行工具区档位/仓库入口按钮的浅色覆盖:不透明层-2 底色与外壳描边别名
3723
+ /* 「回到顶部」与标题行档位按钮及左侧仓库入口的浅色覆盖:不透明层-2 底色与外壳描边别名
3675
3724
  (R-01-018/AC-05、R-01-021/AC-05、R-01-022/AC-01);仓库入口无描边(T-139),只并入底色组。 */
3676
3725
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
3677
3726
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density,
@@ -4707,7 +4756,7 @@ function apply(ctx) {
4707
4756
  const onTopClick = () => {
4708
4757
  scroll?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
4709
4758
  };
4710
- // 显示档位循环切换(R-01-021/AC-01):完整→中间→紧凑→完整,形态写窗格根属性
4759
+ // 显示档位循环切换(R-01-021/AC-01):紧凑→中间→完整→紧凑,形态写窗格根属性
4711
4760
  // 驱动纯 CSS 呈现,持久化于 localStorage(AC-06),会话状态变化不触碰已选档位(AC-07)。
4712
4761
  const densityBtn = pane.querySelector(".dap-density");
4713
4762
  const applyDensity = () => {
@@ -4779,14 +4828,14 @@ function apply(ctx) {
4779
4828
  center.insertBefore(pane, seat);
4780
4829
  pane.innerHTML = `
4781
4830
  <div class="dap-header">
4831
+ <a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener" aria-label="报告问题" title="报告问题,点赞收藏"></a>
4782
4832
  <span class="dap-titlebar" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
4783
4833
  <span>活动会话</span>
4784
4834
  <span class="dap-count" role="status" aria-live="polite"></span>
4785
4835
  <span class="dap-collapse-hint" aria-hidden="true"></span>
4786
4836
  </span>
4787
4837
  <span class="dap-tools">
4788
- <a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener" aria-label="报告问题" title="报告问题"></a>
4789
- <button class="dap-density" type="button" aria-label="切换为紧凑显示" title="紧凑显示"></button>
4838
+ <button class="dap-density" type="button" aria-label="切换为完整显示" title="完整显示"></button>
4790
4839
  </span>
4791
4840
  </div>
4792
4841
  <div class="dap-scroll">
@@ -4809,7 +4858,7 @@ function apply(ctx) {
4809
4858
  // 紧凑显示切换按钮常显于标题行右侧工具区(R-01-021/AC-05);切换时以 data-density
4810
4859
  // 驱动纯 CSS 呈现,骨架重建后由 bindPaneControls 的 applyDensity 恢复形态。
4811
4860
  pane.querySelector(".dap-density").append(createDensityIcon());
4812
- // 仓库入口常显于标题行右侧工具区(R-01-022/AC-01);图标在创建时注入。
4861
+ // 仓库入口常显于标题行最左独立区(R-01-022/AC-01,T-144);图标在创建时注入。
4813
4862
  pane.querySelector(".dap-repo").append(createRepoIcon());
4814
4863
  // 收起方向图标为标题行悬停/聚焦的可见性提示(R-01-011/AC-07),图标在创建时注入。
4815
4864
  pane.querySelector(".dap-collapse-hint").append(createCollapseIcon());
@@ -5016,7 +5065,7 @@ function apply(ctx) {
5016
5065
  });
5017
5066
  }
5018
5067
 
5019
- /** 标题行工具区的仓库入口图标(R-01-022/AC-01):GitHub Octicon `mark-github` 字形
5068
+ /** 标题行左侧独立区的仓库入口图标(R-01-022/AC-01,T-144):GitHub Octicon `mark-github` 字形
5020
5069
  * (MIT 许可,Primer Octicons),与工具区既有图标同用 14px 字形盒。 */
5021
5070
  function createRepoIcon() {
5022
5071
  return createInlineIcon({
@@ -6192,7 +6241,12 @@ function apply(ctx) {
6192
6241
  for (const [id, state] of progressAnchorById) {
6193
6242
  if (delegationActive(state, now)) delegatingIds.add(id);
6194
6243
  }
6195
- const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds);
6244
+ // 排序键注入(契约见 buildEntries JSDoc):openWaitStart 已在 busy SSE 快照归一为毫秒数或 null。
6245
+ const waitingStarts = new Map();
6246
+ for (const [id, record] of busyById) {
6247
+ if (typeof record?.openWaitStart === "number") waitingStarts.set(String(id), record.openWaitStart);
6248
+ }
6249
+ const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds, waitingStarts);
6196
6250
  // 轮内订阅仅对"运行中"会话建立(主会话 + 运行中的子代理),保持在运行中的订阅
6197
6251
  // 数量 == 运行中会话数量(R-02-004/AC-01);暂停等待的子代理只显示标题。
6198
6252
  const runLikeIds = new Set(
package/README.md CHANGED
@@ -36,7 +36,7 @@ The npm package ships prebuilt, so no local build step is needed. If the pane do
36
36
 
37
37
  ## Requirements
38
38
 
39
- - DSH (DeepSeek Harness) web, tested against `@deepseek-ai/dsh@0.1.0-rc.7`.
39
+ - DSH (DeepSeek Harness) web, tested against `@deepseek-ai/dsh@0.1.5-rc.1`.
40
40
  - No third-party plugin dependencies: the pane only consumes DSH's native session and workspace services, and uninstalling is fully reversible.
41
41
 
42
42
  ## Acknowledgments & Disclaimers
package/README.zh-CN.md CHANGED
@@ -36,7 +36,7 @@ npm 包内置预构建产物,无需本地构建步骤。安装后如窗格未
36
36
 
37
37
  ## 环境要求
38
38
 
39
- - DSH (DeepSeek Harness) Web,经 `@deepseek-ai/dsh@0.1.0-rc.7` 实测验证。
39
+ - DSH (DeepSeek Harness) Web,经 `@deepseek-ai/dsh@0.1.5-rc.1` 实测验证。
40
40
  - 无第三方插件依赖:窗格只消费 DSH 原生会话与工作区服务,卸载可逆。
41
41
 
42
42
  ## 感谢与声明
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-activity-pane",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -200,8 +200,8 @@ const steps = [
200
200
  "准备至少 21 个已完成且已移入历史的主会话:首次打开历史区只显示按最近活动时间排序的 10 张卡,并在列表底部显示「加载更多...」按钮;将窗格滚动到底部,确认不会自动追加,点击按钮后才追加后 10 张且前 10 张保留、无重复;再次点击按钮确认只追加最后 1 张、按钮随后隐藏,继续滚动不再增加;追加过程中活动区仍与历史区互斥,窗格滚动不带动主会话页面,桌面与移动端抽屉均可正常继续浏览。另以少于 10 张历史会话确认首屏全部显示、不显示加载按钮且不会自动补页。",
201
201
  // R-01-018/AC-02、AC-04、AC-05 观感残留(键盘/显隐/回顶/窄条/抽屉行为断言已迁 e2e/specs/back-to-top.mjs)
202
202
  "确认平滑滚动动画流畅自然;切到 <=767px 移动视口打开抽屉,确认抽屉内按钮不遮挡标题行;深浅主题各验一次不透明底色与图标可辨。",
203
- // R-01-011/AC-03 标题区整体收起
204
- "桌面宽度下点击「活动会话」标题区(标题与计数徽标一侧,不含右侧工具区),确认窗格折叠为不占用主会话宽度的窄条;点击工具区切换按钮仅切换档位、不触发折叠;键盘聚焦标题区按 Enter/Space 亦可收起。",
203
+ // R-01-011/AC-03 标题区整体收起(T-144:标题区界定不含最左仓库入口区)
204
+ "桌面宽度下点击「活动会话」标题区(标题与计数徽标一侧,不含最左仓库入口区与右侧工具区),确认窗格折叠为不占用主会话宽度的窄条;点击工具区切换按钮仅切换档位、不触发折叠;键盘聚焦标题区按 Enter/Space 亦可收起。",
205
205
  // R-01-011/AC-04 窄条竖排标题整体展开
206
206
  "折叠态下确认窄条以竖排显示「活动会话」标题与活动计数徽标,悬停窄条出现与展开态标题行对等的高亮反馈,点击窄条任意位置展开并恢复活动区+历史区显示。",
207
207
  // R-01-011/AC-06 移动端标题行即收起抽屉
@@ -211,11 +211,11 @@ const steps = [
211
211
  // R-02-004/AC-01 订阅断开
212
212
  "在 DevTools 中观察:会话停止运行后,其轮内状态订阅被断开(无残留监听/继续更新);卸载插件后订阅归零、控制台干净。",
213
213
  // R-01-021/AC-01、R-01-021/AC-02、R-01-021/AC-03、R-01-021/AC-04、R-01-021/AC-05、R-01-021/AC-06、R-01-021/AC-07、R-01-021/AC-08 交互与刷新恢复 → e2e/specs/compact-density.mjs(AC-03 类别底色观感、AC-05 相对位置与图标辨识残留人工)
214
- "连续点击「活动会话」标题行右侧工具区的切换按钮:确认卡片按完整→中间→紧凑→完整循环,中间档保留工作区徽标行、标题行、等待胶囊与正文(等待卡)或用户/助手预览行(历史卡)、隐藏时间线与统计;每次切换后当前选中卡片的顶部在屏幕上位置稳定不跳变;等待行动卡与运行卡的卡面类别底色在中间与紧凑下仍一眼可辨、与完整呈现一致,深浅主题各验一次。",
214
+ "连续点击「活动会话」标题行右侧工具区的切换按钮:确认卡片按紧凑→中间→完整→紧凑循环,中间档保留工作区徽标行、标题行、等待胶囊与正文(等待卡)或用户/助手预览行(历史卡)、隐藏时间线与统计;每次切换后当前选中卡片的顶部在屏幕上位置稳定不跳变;等待行动卡与运行卡的卡面类别底色在中间与紧凑下仍一眼可辨、与完整呈现一致,深浅主题各验一次。",
215
215
  // R-01-021/AC-05 抽屉形态观感
216
216
  "切到 <=767px 移动视口打开抽屉,确认切换按钮同样常显于抽屉内标题行右侧工具区,图标与命中区在触屏下可辨可用;折叠为桌面窄条后按钮不显示。",
217
217
  // R-01-022/AC-01 仓库入口观感(链接目标/激活隔离/窄条隐藏 → e2e/specs/repo-entry.mjs)
218
- "确认「活动会话」标题行右侧工具区的 GitHub 图标按钮位于档位切换按钮左侧、无边框弱化呈现,悬停提示为「报告问题」,深浅主题下图标与底色协调、命中区在触屏与鼠标下可辨可用;点击后在新标签页打开插件仓库页,原会话页保持不动(当前选中会话与呈现档位不变);切到 <=767px 移动视口打开抽屉,确认仓库入口同样常显于抽屉内标题行右侧工具区;桌面折叠为窄条后不显示。",
218
+ "确认「活动会话」标题行最左侧独立区的 GitHub 图标按钮与右侧工具区的档位切换按钮分处标题行两端、无边框弱化呈现,与标题区的接缝间距和标题区与档位按钮的接缝间距对称(悬停高亮两侧留白对称、不贴任一按钮),悬停提示为「报告问题,点赞收藏」,深浅主题下图标与底色协调、命中区在触屏与鼠标下可辨可用;点击后在新标签页打开插件仓库页,原会话页保持不动(当前选中会话与呈现档位不变);切到 <=767px 移动视口打开抽屉,确认仓库入口同样常显于抽屉内标题行最左且点按档位按钮不致误触;桌面折叠为窄条后不显示。",
219
219
  ];
220
220
 
221
221
  const line = "=".repeat(64);
package/scripts/check.mjs CHANGED
@@ -63,9 +63,12 @@ import {
63
63
  lastTurnBusyFromEvents,
64
64
  lastTurnDuration,
65
65
  applyTurnEventToStats,
66
+ createSessionEventSerializer,
66
67
  reconcileTurnStats,
67
68
  normalizeBusyMs,
68
69
  totalBusyDisplayMs,
70
+ turnStatsEqual,
71
+ turnStatsFrom,
69
72
  pendingText,
70
73
  progressHalfLifeSec,
71
74
  progressOf,
@@ -873,26 +876,32 @@ assert.deepEqual(
873
876
  "全运行中场景按宿主列表时间从新到旧;相同时间保持宿主列表出现顺序;缺失视为最旧;子代理不参与排序、始终跟随其母会话(R-01-001/AC-07)",
874
877
  );
875
878
 
876
- // ---- R-01-001/AC-07 运行中置顶;等待/完成组按进入状态时刻(回合结束登记)倒序 ----
879
+ // ---- R-01-001/AC-07 运行中置顶;等待/完成组按进入状态时刻倒序
880
+ // (阻塞等待取回合内等待边界开启时刻 T-141;完成/错误提醒取回合结束登记时刻) ----
877
881
  const mixedWorkspace = [{ title: "Ops", path: "/srv/ops", sessionIds: ["sRunOld"] }];
878
882
  const mixedActivity = {
879
883
  ids: ["sDone1", "sRunOld", "sWait", "sDone2", "sWaitNoRec"],
880
884
  byId: {
881
885
  sDone1: { id: "sDone1", displayTitle: "完成一", running: false, updatedAt: 500 },
882
886
  sRunOld: { id: "sRunOld", displayTitle: "运行旧指令", running: true, updatedAt: 1_000 },
883
- sWait: { id: "sWait", displayTitle: "阻塞等待", running: false, pendingInteraction: "approval", updatedAt: 600 },
887
+ // T-141 复现形态(docsim 实测):等待进行中的会话 lastTurnEnd 停留在上一回合
888
+ // (1_500,早于全部完成提醒),进入等待时刻(9_000)晚于全部完成提醒;宿主列表
889
+ // 时间(3_500)介于两个完成提醒之间,用于区分「用 openWaitStart」与「回落」两条路径。
890
+ sWait: { id: "sWait", displayTitle: "阻塞等待", running: false, pendingInteraction: "approval", updatedAt: 3_500 },
884
891
  sDone2: { id: "sDone2", displayTitle: "完成二", running: false, updatedAt: 700 },
892
+ // 无等待记账记录的阻塞等待会话:回落宿主列表时间(最后用户指令时刻)。
885
893
  sWaitNoRec: { id: "sWaitNoRec", displayTitle: "无登记等待", running: false, pendingInteraction: "question", updatedAt: 10_000 },
886
894
  },
887
895
  current: null,
888
896
  };
889
897
  const mixedCompletions = new Map([
890
898
  ["sDone1", { lastTurnEnd: 4_000, lastTurnEndKind: "completed", ackedAt: null }],
891
- ["sWait", { lastTurnEnd: 6_000, lastTurnEndKind: "blocked", ackedAt: null }],
899
+ ["sWait", { lastTurnEnd: 1_500, lastTurnEndKind: "blocked", ackedAt: null }],
892
900
  ["sDone2", { lastTurnEnd: 3_000, lastTurnEndKind: "completed", ackedAt: null }],
893
901
  ]);
902
+ const mixedWaitingStarts = new Map([["sWait", 9_000]]);
894
903
  assert.deepEqual(
895
- buildEntries(mixedActivity, mixedWorkspace, {}, mixedCompletions).map((entry) => [entry.id, entry.kind]),
904
+ buildEntries(mixedActivity, mixedWorkspace, {}, mixedCompletions, null, [], mixedWaitingStarts).map((entry) => [entry.id, entry.kind]),
896
905
  [
897
906
  ["sRunOld", "running"],
898
907
  ["sWaitNoRec", "awaiting"],
@@ -900,7 +909,32 @@ assert.deepEqual(
900
909
  ["sDone1", "awaiting"],
901
910
  ["sDone2", "awaiting"],
902
911
  ],
903
- "运行中置顶(即便其指令时间更旧);等待/完成组按进入状态时刻倒序,无登记回落宿主列表时间(可新于他卡登记时刻,非视为最旧)(R-01-001/AC-07)",
912
+ "运行中置顶(即便其指令时间更旧);等待/完成组按进入状态时刻倒序——阻塞等待按等待边界开启时刻排前(不被上一回合旧登记压后),无记账回落宿主列表时间,完成/错误提醒按回合结束登记倒序(R-01-001/AC-07,T-141)",
913
+ );
914
+ // waitingStarts 缺失(旧调用/数据在途帧):阻塞等待会话回落宿主列表时间而非上一回合旧登记——
915
+ // sWait 键取 updatedAt=3_500,排在 sDone1(4_000) 之后、sDone2(3_000) 之前,不按 1_500 沉底。
916
+ assert.deepEqual(
917
+ buildEntries(mixedActivity, mixedWorkspace, {}, mixedCompletions).map((entry) => [entry.id, entry.kind]),
918
+ [
919
+ ["sRunOld", "running"],
920
+ ["sWaitNoRec", "awaiting"],
921
+ ["sDone1", "awaiting"],
922
+ ["sWait", "awaiting"],
923
+ ["sDone2", "awaiting"],
924
+ ],
925
+ "waitingStarts 缺失时阻塞等待回落宿主列表时间(仍非上一回合旧登记),完成/错误提醒不受影响(R-01-001/AC-07,T-141)",
926
+ );
927
+ // 非 Map 入参视为无数据,与上一断言同路径。
928
+ assert.deepEqual(
929
+ buildEntries(mixedActivity, mixedWorkspace, {}, mixedCompletions, null, [], "not-a-map").map((entry) => [entry.id, entry.kind]),
930
+ buildEntries(mixedActivity, mixedWorkspace, {}, mixedCompletions).map((entry) => [entry.id, entry.kind]),
931
+ "waitingStarts 非 Map 视为无数据(R-01-001/AC-07,T-141)",
932
+ );
933
+ // 记录值非法(null/空串经 Number 归一为 0 的形状)同样按无数据处理,回落宿主列表时间而非最旧时刻 0。
934
+ assert.deepEqual(
935
+ buildEntries(mixedActivity, mixedWorkspace, {}, mixedCompletions, null, [], new Map([["sWait", null], ["sWaitNoRec", ""]])).map((entry) => [entry.id, entry.kind]),
936
+ buildEntries(mixedActivity, mixedWorkspace, {}, mixedCompletions).map((entry) => [entry.id, entry.kind]),
937
+ "waitingStarts 非法值(null/空串)不作数、回落宿主列表时间,不误判为最旧时刻 0(R-01-001/AC-07,T-141 复审)",
904
938
  );
905
939
  assert.deepEqual(trackRuns([{ kind: "subagent", parentId: "p", depth: 1 }]), [], "无 id 条目不产生轨道");
906
940
  assert.deepEqual(
@@ -1231,6 +1265,39 @@ assert.equal(fmtElapsedMs(-1), "", "负时长归一为空");
1231
1265
  }
1232
1266
  assert.equal(stats.busyMs, 1_000, "R-01-020/AC-04 水位检查下重复推送不重复计数");
1233
1267
  }
1268
+ // R-01-020/AC-02:实时登记的会话级串行化——宿主 KvTable.put 先落盘后更新内存快照,
1269
+ // 并发到达的边界事件若各自读-改-写,后到事件会在先到事件落盘前读到旧状态,其效果被
1270
+ // 当作「无效果」永久丢弃(实测:提问 tool/call 与 11ms 后的 tool/result 结算丢失,
1271
+ // 等待区间悬挂到 turn/end 强制结算,总耗时缺记整段运行过程)。串行化后即便全部 handler
1272
+ // 同步注册、put 延迟落盘,最终记账也必须与顺序重放一致。
1273
+ {
1274
+ const events = [
1275
+ { type: "turn/start", seq: 1, time: 1_000 },
1276
+ { type: "tool/call", seq: 2, time: 2_000, data: { callId: "c1", name: "ask_user_question" } },
1277
+ { type: "tool/result", seq: 3, time: 2_011, data: { message: { source: { kind: "tool", callId: "c1" } } } },
1278
+ { type: "turn/end", seq: 4, time: 5_000, data: { reason: { kind: "completed" } } },
1279
+ ];
1280
+ let sequential = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null };
1281
+ for (const event of events) sequential = applyTurnEventToStats(sequential, event);
1282
+ assert.equal(sequential.busyMs, 4_000 - 11, "R-01-020/AC-02 顺序重放基线:回合 4s 扣提问等待 11ms");
1283
+ // 全部事件同步注册(与 session/event 同步观察者的派发序一致);模拟宿主 KvTable:
1284
+ // put 落盘(异步延迟)完成后才更新内存快照。
1285
+ const serialize = createSessionEventSerializer();
1286
+ const memory = new Map();
1287
+ await Promise.all(
1288
+ events.map((event) =>
1289
+ serialize("session-under-test", async () => {
1290
+ const current = turnStatsFrom(memory.get("session-under-test") ?? null);
1291
+ const next = applyTurnEventToStats(current, event);
1292
+ if (turnStatsEqual(next, current)) return;
1293
+ await new Promise((resolve) => setTimeout(resolve, 5));
1294
+ memory.set("session-under-test", next);
1295
+ }),
1296
+ ),
1297
+ );
1298
+ assert.equal(memory.get("session-under-test")?.busyMs, 4_000 - 11, "R-01-020/AC-02 会话级串行化下并发登记与顺序重放一致");
1299
+ assert.equal(memory.get("session-under-test")?.openTurnStart, null, "R-01-020/AC-02 串行化登记后回合闭合无残留起点");
1300
+ }
1234
1301
  // R-01-020/AC-01
1235
1302
  // R-01-020/AC-03:开放回合实时增量与起点不可得降级。
1236
1303
  assert.equal(totalBusyDisplayMs({ busyMs: 3_500, openTurnStart: 30_000, now: 32_000 }), 5_500, "累计加开放回合实时已耗时");
@@ -3406,10 +3473,10 @@ assert.equal(normalizeDensity("full"), "full", "完整持久化值恢复为完
3406
3473
  assert.equal(normalizeDensity(null), "medium", "无持久化记录回退默认中间档");
3407
3474
  assert.equal(normalizeDensity(""), "medium", "空串回退默认中间档");
3408
3475
  assert.equal(normalizeDensity("abc"), "medium", "非法持久化值回退默认中间档");
3409
- assert.equal(nextDensity("full"), "medium", "完整档的下一档为中间(R-01-021/AC-01)");
3410
- assert.equal(nextDensity("medium"), "compact", "中间档的下一档为紧凑(R-01-021/AC-01)");
3411
- assert.equal(nextDensity("compact"), "full", "紧凑档的下一档回到完整(R-01-021/AC-01)");
3412
- assert.equal(nextDensity("junk"), "compact", "非法值经归一视作默认中间档再循环(R-01-021/AC-01)");
3476
+ assert.equal(nextDensity("full"), "compact", "完整档的下一档为紧凑——循环自小到大(R-01-021/AC-01)");
3477
+ assert.equal(nextDensity("medium"), "full", "中间档的下一档为完整(R-01-021/AC-01)");
3478
+ assert.equal(nextDensity("compact"), "medium", "紧凑档的下一档为中间(R-01-021/AC-01)");
3479
+ assert.equal(nextDensity("junk"), "full", "非法值经归一视作默认中间档再循环(R-01-021/AC-01)");
3413
3480
 
3414
3481
  // ---- 重建 client bundle 并校验产物契约 ----
3415
3482
  await mkdir(join(root, ".dsh-plugin"), { recursive: true });
@@ -3503,20 +3570,20 @@ assert.ok(
3503
3570
  bundle.includes('[data-dsh-activity-pane] .dap-density {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 22px;'),
3504
3571
  "切换按钮常显于标题行右侧工具区(R-01-021/AC-05)",
3505
3572
  );
3506
- // R-01-022/AC-01 标题行工具区仓库入口(GitHub 链接、新标签页打开)
3573
+ // R-01-022/AC-01 标题行左侧独立区仓库入口(GitHub 链接、新标签页打开)
3507
3574
  assert.ok(
3508
3575
  bundle.includes('<a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener"'),
3509
- "仓库入口随窗格骨架创建于标题行工具区,指向 GitHub 仓库页并以 target=_blank 与 noreferrer noopener 在新标签页打开(R-01-022/AC-01)",
3576
+ "仓库入口随窗格骨架创建于标题行左侧独立区,指向 GitHub 仓库页并以 target=_blank 与 noreferrer noopener 在新标签页打开(R-01-022/AC-01)",
3510
3577
  );
3511
3578
  assert.ok(
3512
3579
  bundle.includes('[data-dsh-activity-pane] .dap-repo {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 22px;\n border-radius: 999px;\n background: #1d1f25;'),
3513
- "仓库入口与工具区按钮同视觉语言且无边框(22px 圆形纯图标、视觉强度弱于带描边的档位切换按钮,R-01-022/AC-01、T-139)",
3580
+ "仓库入口与档位切换按钮同视觉语言且无边框(22px 圆形纯图标、视觉强度弱于带描边的档位切换按钮,R-01-022/AC-01、T-139)",
3514
3581
  );
3515
- // T-139:仓库入口弱化——位于档位切换按钮左侧(模板顺序)、悬停提示「报告问题」。
3582
+ // T-144:仓库入口移至标题行最左独立区、与档位切换按钮分处标题行两端——模板顺序上仓库入口最先出现。
3516
3583
  assert.ok(
3517
- bundle.includes('aria-label="报告问题" title="报告问题"')
3584
+ bundle.includes('aria-label="报告问题" title="报告问题,点赞收藏"')
3518
3585
  && bundle.indexOf('<a class="dap-repo"') < bundle.indexOf('<button class="dap-density"'),
3519
- "仓库入口位于档位切换按钮左侧且悬停提示为「报告问题」(T-139)",
3586
+ "仓库入口位于标题行最左独立区、与档位切换按钮分处标题行两端且悬停提示为「报告问题,点赞收藏」(T-139、T-144、T-145)",
3520
3587
  );
3521
3588
  // R-01-015/AC-03 折叠窄条与移动端抽屉不提供拖拽
3522
3589
  assert.ok(bundle.includes('[data-collapsed="true"] .dap-resize { display: none; }'), "折叠窄条不提供拖拽调宽");
package/src/client.mjs CHANGED
@@ -78,8 +78,9 @@ const CSS = `
78
78
  font-weight: 700;
79
79
  letter-spacing: 0.02em;
80
80
  }
81
- /* 标题行拆为两部分:左侧标题区(flex:1 占满剩余宽度)整体即收起控件,悬停/聚焦
82
- 高亮只覆盖标题区(R-01-011/AC-03、AC-07、R-01-008/AC-02);右侧工具区独立,
81
+ /* 标题行拆为三部分:最左仓库入口独立区(T-144,与工具区按钮分处标题行两端)、左侧标题区
82
+ (flex:1 占满剩余宽度)整体即收起控件,悬停/聚焦高亮只覆盖标题区
83
+ (R-01-011/AC-03、AC-07、R-01-008/AC-02);右侧工具区独立,
83
84
  不参与标题区的悬停高亮与折叠激活。 */
84
85
  [data-dsh-activity-pane] .dap-titlebar {
85
86
  flex: 1;
@@ -218,7 +219,11 @@ const CSS = `
218
219
  [data-dsh-activity-pane] .dap-density:focus-visible {
219
220
  background: #262932;
220
221
  }
221
- /* 仓库入口无描边(T-139):视觉强度弱于带描边的档位切换按钮,仅以不透明底色圆形呈现。 */
222
+ /* 仓库入口移至标题行最左独立区(T-144,R-01-022/AC-01):与右侧工具区的档位切换按钮
223
+ 分处标题行两端以消除触屏误触;无描边(T-139),视觉强度弱于带描边的档位切换按钮,仅以不透明
224
+ 底色圆形呈现。原依赖 .dap-tools 的 padding 由自身 margin 承担(圆形底色盒不可用
225
+ padding 扩容);左右 margin 各 12px——右缘与标题区的接缝间距对齐工具区的接缝间距
226
+ (tools padding-left 12px),标题区悬停高亮两侧留白对称(T-146)。 */
222
227
  [data-dsh-activity-pane] .dap-repo {
223
228
  display: flex;
224
229
  align-items: center;
@@ -230,6 +235,7 @@ const CSS = `
230
235
  color: inherit;
231
236
  cursor: pointer;
232
237
  text-decoration: none;
238
+ margin: 0 12px;
233
239
  }
234
240
  [data-dsh-activity-pane] .dap-repo:hover,
235
241
  [data-dsh-activity-pane] .dap-repo:focus-visible {
@@ -937,8 +943,8 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
937
943
  }
938
944
  /* 移动端抽屉透明遮罩:抽屉打开时铺满视口、点击收起抽屉(R-01-008/AC-03)。
939
945
  完全透明不占布局;z-index 介于主会话与抽屉(2147482990)之间;抽屉打开期间
940
- 浮动开关隐藏(见 .dap-toggle[data-drawer-open],R-01-008/AC-05),关闭由抽屉
941
- 头部 × 与遮罩承担;桌面断点外由媒体查询保持隐藏。 */
946
+ 浮动开关隐藏(见 .dap-toggle[data-drawer-open],R-01-008/AC-05),关闭由
947
+ 标题行激活(无独立 × 关闭按钮,T-137)与遮罩承担;桌面断点外由媒体查询保持隐藏。 */
942
948
  .dap-backdrop {
943
949
  position: fixed; inset: 0;
944
950
  z-index: 2147482989;
@@ -1085,7 +1091,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {
1085
1091
  body:not([data-ds-dark-theme]) .dap-toggle {
1086
1092
  background: var(--dsw-alias-button-floating-fill, rgba(255, 255, 255, 0.94));
1087
1093
  }
1088
- /* 「回到顶部」与标题行工具区档位/仓库入口按钮的浅色覆盖:不透明层-2 底色与外壳描边别名
1094
+ /* 「回到顶部」与标题行档位按钮及左侧仓库入口的浅色覆盖:不透明层-2 底色与外壳描边别名
1089
1095
  (R-01-018/AC-05、R-01-021/AC-05、R-01-022/AC-01);仓库入口无描边(T-139),只并入底色组。 */
1090
1096
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
1091
1097
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density,
@@ -2121,7 +2127,7 @@ function apply(ctx) {
2121
2127
  const onTopClick = () => {
2122
2128
  scroll?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
2123
2129
  };
2124
- // 显示档位循环切换(R-01-021/AC-01):完整→中间→紧凑→完整,形态写窗格根属性
2130
+ // 显示档位循环切换(R-01-021/AC-01):紧凑→中间→完整→紧凑,形态写窗格根属性
2125
2131
  // 驱动纯 CSS 呈现,持久化于 localStorage(AC-06),会话状态变化不触碰已选档位(AC-07)。
2126
2132
  const densityBtn = pane.querySelector(".dap-density");
2127
2133
  const applyDensity = () => {
@@ -2193,14 +2199,14 @@ function apply(ctx) {
2193
2199
  center.insertBefore(pane, seat);
2194
2200
  pane.innerHTML = `
2195
2201
  <div class="dap-header">
2202
+ <a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener" aria-label="报告问题" title="报告问题,点赞收藏"></a>
2196
2203
  <span class="dap-titlebar" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
2197
2204
  <span>活动会话</span>
2198
2205
  <span class="dap-count" role="status" aria-live="polite"></span>
2199
2206
  <span class="dap-collapse-hint" aria-hidden="true"></span>
2200
2207
  </span>
2201
2208
  <span class="dap-tools">
2202
- <a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener" aria-label="报告问题" title="报告问题"></a>
2203
- <button class="dap-density" type="button" aria-label="切换为紧凑显示" title="紧凑显示"></button>
2209
+ <button class="dap-density" type="button" aria-label="切换为完整显示" title="完整显示"></button>
2204
2210
  </span>
2205
2211
  </div>
2206
2212
  <div class="dap-scroll">
@@ -2223,7 +2229,7 @@ function apply(ctx) {
2223
2229
  // 紧凑显示切换按钮常显于标题行右侧工具区(R-01-021/AC-05);切换时以 data-density
2224
2230
  // 驱动纯 CSS 呈现,骨架重建后由 bindPaneControls 的 applyDensity 恢复形态。
2225
2231
  pane.querySelector(".dap-density").append(createDensityIcon());
2226
- // 仓库入口常显于标题行右侧工具区(R-01-022/AC-01);图标在创建时注入。
2232
+ // 仓库入口常显于标题行最左独立区(R-01-022/AC-01,T-144);图标在创建时注入。
2227
2233
  pane.querySelector(".dap-repo").append(createRepoIcon());
2228
2234
  // 收起方向图标为标题行悬停/聚焦的可见性提示(R-01-011/AC-07),图标在创建时注入。
2229
2235
  pane.querySelector(".dap-collapse-hint").append(createCollapseIcon());
@@ -2430,7 +2436,7 @@ function apply(ctx) {
2430
2436
  });
2431
2437
  }
2432
2438
 
2433
- /** 标题行工具区的仓库入口图标(R-01-022/AC-01):GitHub Octicon `mark-github` 字形
2439
+ /** 标题行左侧独立区的仓库入口图标(R-01-022/AC-01,T-144):GitHub Octicon `mark-github` 字形
2434
2440
  * (MIT 许可,Primer Octicons),与工具区既有图标同用 14px 字形盒。 */
2435
2441
  function createRepoIcon() {
2436
2442
  return createInlineIcon({
@@ -3606,7 +3612,12 @@ function apply(ctx) {
3606
3612
  for (const [id, state] of progressAnchorById) {
3607
3613
  if (delegationActive(state, now)) delegatingIds.add(id);
3608
3614
  }
3609
- const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds);
3615
+ // 排序键注入(契约见 buildEntries JSDoc):openWaitStart 已在 busy SSE 快照归一为毫秒数或 null。
3616
+ const waitingStarts = new Map();
3617
+ for (const [id, record] of busyById) {
3618
+ if (typeof record?.openWaitStart === "number") waitingStarts.set(String(id), record.openWaitStart);
3619
+ }
3620
+ const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds, waitingStarts);
3610
3621
  // 轮内订阅仅对"运行中"会话建立(主会话 + 运行中的子代理),保持在运行中的订阅
3611
3622
  // 数量 == 运行中会话数量(R-02-004/AC-01);暂停等待的子代理只显示标题。
3612
3623
  const runLikeIds = new Set(
package/src/core.mjs CHANGED
@@ -132,11 +132,11 @@ export function normalizeDensity(raw) {
132
132
  return raw === "compact" || raw === "medium" || raw === "full" ? raw : "medium";
133
133
  }
134
134
 
135
- /** 显示档位的循环次序:完整 → 中间 → 紧凑完整(R-01-021/AC-01)。 */
136
- const DENSITY_ORDER = ["full", "medium", "compact"];
135
+ /** 显示档位的循环次序:紧凑 → 中间 → 完整紧凑——信息量自小到大(R-01-021/AC-01)。 */
136
+ const DENSITY_ORDER = ["compact", "medium", "full"];
137
137
 
138
138
  /**
139
- * 返回循环切换后的下一显示档位:完整 → 中间 → 紧凑完整;
139
+ * 返回循环切换后的下一显示档位:紧凑 → 中间 → 完整紧凑;
140
140
  * 输入先经 normalizeDensity 归一,非法值视作中间档(R-01-021/AC-01)。
141
141
  */
142
142
  export function nextDensity(value) {
@@ -1547,8 +1547,13 @@ export function mainTitle(byId, id) {
1547
1547
  * 始终为当帧原始后代活性(供进度锚点记账判定耗尽),不受 delegatingIds 影响。
1548
1548
  * archivedIds(工作区服务的注册表全局归档集合):归档会话及其有效子代理子树不产出活动条目,
1549
1549
  * 普通 fork 仅因共享来源 parentId 不受影响;避免会话服务仍保留旧行或完成提醒时在窗格中滞留。
1550
+ * waitingStarts(进入阻塞等待的时刻,R-01-001/AC-07):Map id → 毫秒时刻,取宿主回合记账的
1551
+ * `openWaitStart`(approval/asked 与 ask_user_question tool/call 边界);阻塞等待发生时本回合
1552
+ * 尚未结束、`lastTurnEnd` 仍是上一回合的旧时刻,不能作为等待进行中会话的排序键(T-141);
1553
+ * 非 Map、缺失记录或值非有限数字(含 null/空串等 Number 归一为 0 的形状)均视为无数据,
1554
+ * 回落宿主列表时间。
1550
1555
  */
1551
- export function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = []) {
1556
+ export function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = [], waitingStarts = null) {
1552
1557
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1553
1558
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1554
1559
  const current = snapshot?.current ?? null;
@@ -1601,16 +1606,28 @@ export function buildEntries(snapshot, workspaceItems, detailsById = {}, complet
1601
1606
  meta.set(id, { row, running, pending, isSub, show, done, err, descendantActive, delegating, depth: 0 });
1602
1607
  }
1603
1608
 
1604
- // 主会话分两组排序(R-01-001/AC-07):运行中主会话置顶,组内按最后一次用户指令
1605
- // 时间(宿主列表时间)从新到旧;等待/完成组(阻塞等待、完成提醒、错误提醒)排后,
1606
- // 组内按进入该状态的时刻(最近一次回合结束登记时刻,缺失回落宿主列表时间)从新到旧。
1607
- // 两组相同时间均回落宿主列表出现顺序。工作区顺序不参与排序,仅承载卡片徽标与名称。
1609
+ // 主会话分两组排序(R-01-001/AC-07,键口径契约详见上方 JSDoc):运行中主会话置顶、
1610
+ // 组内按宿主列表时间从新到旧;等待/完成组排后、组内按进入状态时刻从新到旧;
1611
+ // 两组相同时间均回落宿主列表出现顺序,工作区顺序不参与排序。
1608
1612
  const isRunningEntry = (id) => {
1609
1613
  const m = meta.get(id);
1610
1614
  return m !== undefined && !m.pending && (m.running || m.delegating);
1611
1615
  };
1616
+ const waitingStartTime = (id) => {
1617
+ if (!(waitingStarts instanceof Map)) return null;
1618
+ const value = waitingStarts.get(String(id));
1619
+ // 仅接受真实数字时刻:Number(null)/Number("") 均为 0,会把「无数据」误判为
1620
+ // epoch 最旧时刻(与 normalizeBusyMs 的空值防护同理),一律回落宿主列表时间。
1621
+ if (typeof value !== "number") return null;
1622
+ return Number.isFinite(value) ? value : null;
1623
+ };
1612
1624
  const sortTime = (id) => {
1613
1625
  if (isRunningEntry(id)) return instructionTime(byId[id]);
1626
+ const m = meta.get(id);
1627
+ if (m !== undefined && m.pending) {
1628
+ const start = waitingStartTime(id);
1629
+ return start ?? instructionTime(byId[id]);
1630
+ }
1614
1631
  const record = completionFor(id, completions);
1615
1632
  const end = isRecord(record) ? Number(record.lastTurnEnd) : NaN;
1616
1633
  return Number.isFinite(end) ? end : instructionTime(byId[id]);
@@ -2230,6 +2247,32 @@ export function reconcileTurnStats(current, records, { closeOpenTurn = false, fo
2230
2247
  return { ...state, watermarkSeq, watermarkTime };
2231
2248
  }
2232
2249
 
2250
+ /**
2251
+ * 会话级登记串行化(R-01-020/AC-02):宿主存储域的 `KvTable.put` 先异步落盘、落盘完成
2252
+ * 后才更新内存快照,因此同一会话的两个边界事件 handler 并发执行时,后到事件会在先到
2253
+ * 事件落盘前读到旧状态,其效果被当作「无效果」永久丢弃(实测:`ask_user_question` 的
2254
+ * `tool/call` 与 11ms 后的 `tool/result` 交错,等待区间悬挂到 `turn/end` 强制结算,
2255
+ * 总耗时系统性缺记整段运行过程)。返回 `(id, run) => tail`:同一会话前一个 run 完成
2256
+ * (含其 put 的内存生效)后才执行下一个 run,不同会话互不阻塞。run 内部须自行捕获
2257
+ * 错误、不得让拒绝外泄(调用方可安全丢弃返回的 tail);tail 供调用方等待与测试断言。
2258
+ * 链空闲即清理,不随会话数增长。
2259
+ */
2260
+ export function createSessionEventSerializer() {
2261
+ const tails = new Map();
2262
+ return (id, run) => {
2263
+ const prev = tails.get(id) ?? Promise.resolve();
2264
+ const tail = prev.then(run, run);
2265
+ tails.set(id, tail);
2266
+ tail.then(
2267
+ () => {
2268
+ if (tails.get(id) === tail) tails.delete(id);
2269
+ },
2270
+ () => {},
2271
+ );
2272
+ return tail;
2273
+ };
2274
+ }
2275
+
2233
2276
  /**
2234
2277
  * 构建历史区条目:当前非活动的**主会话**,按最后活动时间从新到旧返回完整候选集合。
2235
2278
  * 子代理是临时工作单元,不入历史区;归档、空会话、完成/错误提醒与委托周期中的会话
package/src/host.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
28
28
  import { z } from 'zod'
29
- import { applyTurnEventToStats, isBusyBoundaryEvent, reconcileTurnStats, turnStatsEqual, turnStatsFrom, truncateErrorNote } from './core.mjs'
29
+ import { applyTurnEventToStats, createSessionEventSerializer, isBusyBoundaryEvent, reconcileTurnStats, turnStatsEqual, turnStatsFrom, truncateErrorNote } from './core.mjs'
30
30
 
31
31
  export const name = 'dsh-activity-pane'
32
32
  export const inject = ['storageDomain', 'webServer', 'sessionQuery', 'connection']
@@ -266,52 +266,58 @@ export function apply(ctx) {
266
266
  // tool/call)不落盘、不广播;主/子统一登记,过滤由客户端判定。
267
267
  // 表内无记录的会话先懒回填存量回合再应用实时事件——否则首个实时事件会以 null 建立记录、
268
268
  // 存量回合永远失去补齐机会。
269
- ctx.on('session/event', async (session, event) => {
269
+ // 读-改-写经 createSessionEventSerializer 按会话串行(put 先落盘后更新内存,并发
270
+ // 读-改-写会丢先到事件效果,机制见 core.mjs 同名 JSDoc);注册同步进行保持派发序,
271
+ // run 内 catch-all 保证链不因单事件失败中断。
272
+ const serializeBusyEvent = createSessionEventSerializer()
273
+ ctx.on('session/event', (session, event) => {
270
274
  const type = event?.type
271
275
  if (!isBusyBoundaryEvent(type)) return
272
276
  const seq = Number(event.seq)
273
277
  if (!Number.isFinite(seq)) return
274
278
  const id = String(session?.id ?? '')
275
279
  if (id === '') return
276
- try {
277
- await domainReady
278
- if (!turnStats.get(id)) {
279
- await ensureTurnStatsFresh(id)
280
- } else {
281
- // 回填在途时排队等待其写入完成,再按水位应用实时事件,避免回填快照
282
- // 覆盖(回退)实时事件的效果或造成缺口丢失(R-01-020/AC-04)。
283
- const inflight = backfills.get(id)
284
- if (inflight) await inflight
285
- }
286
- let current = turnStats.get(id)
287
- if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) {
288
- // 重编检测(R-01-020/AC-04):稳定 seq 空间内低水位事件的时刻不可能比水位处
289
- // 事件更新;时刻反而更新说明事件 seq 空间被重编(如 dsh 0.1.5 V3 迁移),
290
- // 持久化水位已失效,增量口径不再封死后续事件。无 watermarkTime 的存量记录
291
- //(本字段引入前写入,其水位未经重编检测校验)同样不可信——两者统一丢弃旧
292
- // 记账强制全量重放自愈;重放若仍覆盖不到本事件(日志写入滞后),落回下方
293
- // 正常增量路径应用。
280
+ serializeBusyEvent(id, async () => {
281
+ try {
282
+ await domainReady
283
+ if (!turnStats.get(id)) {
284
+ await ensureTurnStatsFresh(id)
285
+ } else {
286
+ // 回填在途时排队等待其写入完成,再按水位应用实时事件,避免回填快照
287
+ // 覆盖(回退)实时事件的效果或造成缺口丢失(R-01-020/AC-04)。
288
+ const inflight = backfills.get(id)
289
+ if (inflight) await inflight
290
+ }
291
+ let current = turnStats.get(id)
292
+ if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) {
293
+ // 重编检测(R-01-020/AC-04):稳定 seq 空间内低水位事件的时刻不可能比水位处
294
+ // 事件更新;时刻反而更新说明事件 seq 空间被重编(如 dsh 0.1.5 V3 迁移),
295
+ // 持久化水位已失效,增量口径不再封死后续事件。无 watermarkTime 的存量记录
296
+ //(本字段引入前写入,其水位未经重编检测校验)同样不可信——两者统一丢弃旧
297
+ // 记账强制全量重放自愈;重放若仍覆盖不到本事件(日志写入滞后),落回下方
298
+ // 正常增量路径应用。
299
+ const time = Number(event.time)
300
+ if (!Number.isFinite(time)) return
301
+ if (Number.isFinite(current.watermarkTime) && time <= current.watermarkTime) return
302
+ await ensureTurnStatsFresh(id, { forceFresh: true })
303
+ current = turnStats.get(id)
304
+ if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) return
305
+ }
306
+ const next = applyTurnEventToStats(turnStatsFrom(current), event)
307
+ if (turnStatsEqual(next, current)) {
308
+ // 无效果事件(非提问工具调用、未配对的结算事件等):不落盘不广播,
309
+ // 水位不前移——重放路径对同类事件同样不改变记账状态,口径一致。
310
+ return
311
+ }
294
312
  const time = Number(event.time)
295
- if (!Number.isFinite(time)) return
296
- if (Number.isFinite(current.watermarkTime) && time <= current.watermarkTime) return
297
- await ensureTurnStatsFresh(id, { forceFresh: true })
298
- current = turnStats.get(id)
299
- if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) return
300
- }
301
- const next = applyTurnEventToStats(turnStatsFrom(current), event)
302
- if (turnStatsEqual(next, current)) {
303
- // 无效果事件(非提问工具调用、未配对的结算事件等):不落盘不广播,
304
- // 水位不前移——重放路径对同类事件同样不改变记账状态,口径一致。
305
- return
313
+ // reconcile 同口径:时刻无效时保留既有 watermarkTime 不前推(能走到此处的
314
+ // 必为有效果事件、时刻有效,null 分支仅作防御)。
315
+ await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: seq, watermarkTime: Number.isFinite(time) ? time : turnStatsFrom(current).watermarkTime ?? null })
316
+ broadcastBusy()
317
+ } catch (error) {
318
+ ctx.logger?.warn?.(`dsh-activity-pane: 回合统计登记失败(${id}): ${String(error)}`)
306
319
  }
307
- const time = Number(event.time)
308
- // 与 reconcile 同口径:时刻无效时保留既有 watermarkTime 不前推(能走到此处的
309
- // 必为有效果事件、时刻有效,null 分支仅作防御)。
310
- await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: seq, watermarkTime: Number.isFinite(time) ? time : turnStatsFrom(current).watermarkTime ?? null })
311
- broadcastBusy()
312
- } catch (error) {
313
- ctx.logger?.warn?.(`dsh-activity-pane: 回合统计登记失败(${id}): ${String(error)}`)
314
- }
320
+ })
315
321
  })
316
322
 
317
323
  // HTTP API(前缀挂载,handler 内按子路径分发)。