dsh-activity-pane 0.8.0 → 0.9.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.
@@ -1985,58 +1985,30 @@ function durationTime(value) {
1985
1985
  return Number.isFinite(time) ? time : null;
1986
1986
  }
1987
1987
 
1988
- /** 从 history 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1989
- function lastTurnDurationCandidateFromEvents(events) {
1990
- const starts = new Map();
1988
+ /**
1989
+ * 从 history 重放回合/等待边界事件,提取最近一个已结束回合的运行过程耗时(busy 口径:
1990
+ * 起止差值扣除回合内阻塞等待,与 R-01-020 累计口径一致,恒不大于该回合墙钟时长)。
1991
+ * 回合起止不完整、时间逆序或回合运行段不为正时忽略该回合;无可得回合返回 null。
1992
+ */
1993
+ function lastTurnBusyFromEvents(events) {
1994
+ let state = emptyTurnStats();
1991
1995
  let latest = null;
1992
1996
  for (const entry of Array.isArray(events) ? events : []) {
1993
- const event = eventOf(entry);
1994
- const turn = Number(event?.data?.turn);
1995
- const time = durationTime(event?.time);
1996
- if (!Number.isFinite(turn) || time === null) continue;
1997
- if (event.type === "turn/start") {
1998
- starts.set(turn, time);
1999
- continue;
2000
- }
2001
- if (event.type !== "turn/end") continue;
2002
- const start = starts.get(turn);
2003
- if (start === undefined || time < start) continue;
2004
- if (latest === null || time > latest.end) latest = { end: time, duration: time - start };
2005
- }
2006
- return latest;
2007
- }
2008
-
2009
- /** 从 turnTimings 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
2010
- function lastTurnDurationCandidateFromTimings(turnTimings) {
2011
- if (!(turnTimings instanceof Map)) return null;
2012
- let latest = null;
2013
- for (const timing of turnTimings.values()) {
2014
- const start = durationTime(timing?.startTime);
2015
- const end = durationTime(timing?.endTime);
2016
- if (start === null || end === null || end < start) continue;
2017
- if (latest === null || end > latest.end) latest = { end, duration: end - start };
1997
+ const time = durationTime(eventOf(entry)?.time);
1998
+ const prev = state;
1999
+ state = applyTurnEventToStats(state, entry);
2000
+ if (prev.openTurnStart === null || state.openTurnStart !== null) continue;
2001
+ // 能走到回合闭合的必为时刻有效的 turn/end(applyTurnEventToStats 对无效时刻无效果)。
2002
+ const activeMs = (state.busyMs ?? 0) - (prev.busyMs ?? 0);
2003
+ if (activeMs <= 0) continue;
2004
+ latest = { end: time, duration: activeMs };
2018
2005
  }
2019
- return latest;
2020
- }
2021
-
2022
- /** 从 history 提取最近完整回合的固定耗时;回合起止不完整或逆序时忽略该回合。 */
2023
- function lastTurnDurationFromEvents(events) {
2024
- return lastTurnDurationCandidateFromEvents(events)?.duration ?? null;
2006
+ return latest?.duration ?? null;
2025
2007
  }
2026
2008
 
2027
- /** turnTimings 提取最近完整回合的固定耗时;全部回合未结束或无效时返回 null。 */
2028
- function lastTurnDurationFromTimings(turnTimings) {
2029
- return lastTurnDurationCandidateFromTimings(turnTimings)?.duration ?? null;
2030
- }
2031
-
2032
- /** 在快照与 history 中按最近结束时刻选择同一最新完整回合的固定耗时。 */
2033
- function lastTurnDuration({ turnTimings = null, history = [] } = {}) {
2034
- const candidates = [lastTurnDurationCandidateFromTimings(turnTimings), lastTurnDurationCandidateFromEvents(history)].filter(Boolean);
2035
- let latest = null;
2036
- for (const candidate of candidates) {
2037
- if (latest === null || candidate.end > latest.end) latest = candidate;
2038
- }
2039
- return latest?.duration ?? null;
2009
+ /** 最近完整回合的固定耗时(busy 口径):从 history 事件重放派生。 */
2010
+ function lastTurnDuration({ history = [] } = {}) {
2011
+ return lastTurnBusyFromEvents(history);
2040
2012
  }
2041
2013
 
2042
2014
  /** ask_user_question 工具名:提问/计划审查等待的开启边界(tool/call)与配对结算(tool/result)。 */
@@ -2052,7 +2024,7 @@ function isBusyBoundaryEvent(type) {
2052
2024
 
2053
2025
  /** 空记账状态(无任何有效计时)。 */
2054
2026
  function emptyTurnStats() {
2055
- return { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null };
2027
+ return { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null, watermarkTime: null };
2056
2028
  }
2057
2029
 
2058
2030
  /** 从任意来源拷贝记账状态:缺失字段归一为 null(旧记录/部分记录兼容)。 */
@@ -2064,10 +2036,14 @@ function turnStatsFrom(state) {
2064
2036
  openWaitKind: state?.openWaitKind ?? null,
2065
2037
  openWaitId: state?.openWaitId ?? null,
2066
2038
  waitedMs: state?.waitedMs ?? null,
2039
+ watermarkTime: state?.watermarkTime ?? null,
2067
2040
  };
2068
2041
  }
2069
2042
 
2070
- /** 记账状态逐字段相等(不含 watermarkSeq——宿主侧无效果事件跳过落盘的判定)。 */
2043
+ /**
2044
+ * 记账状态逐字段相等(不含 watermarkSeq——它不是状态字段,由宿主侧随写入一并落盘;
2045
+ * watermarkTime 为水位处已覆盖事件的时刻,参与相等判定以让补写该字段的收敛写入生效)。
2046
+ */
2071
2047
  function turnStatsEqual(a, b) {
2072
2048
  return (
2073
2049
  a === b ||
@@ -2076,7 +2052,8 @@ function turnStatsEqual(a, b) {
2076
2052
  (a?.openWaitStart ?? null) === (b?.openWaitStart ?? null) &&
2077
2053
  (a?.openWaitKind ?? null) === (b?.openWaitKind ?? null) &&
2078
2054
  (a?.openWaitId ?? null) === (b?.openWaitId ?? null) &&
2079
- (a?.waitedMs ?? null) === (b?.waitedMs ?? null))
2055
+ (a?.waitedMs ?? null) === (b?.waitedMs ?? null) &&
2056
+ (a?.watermarkTime ?? null) === (b?.watermarkTime ?? null))
2080
2057
  );
2081
2058
  }
2082
2059
 
@@ -2198,15 +2175,18 @@ function totalBusyDisplayMs({ busyMs = null, openTurnStart = null, waitedMs = nu
2198
2175
 
2199
2176
  /**
2200
2177
  * 回合统计记账的统一收敛(R-01-020/AC-04、AC-05):对全会话事件列表重放出下一份
2201
- * 记账,宿主侧懒回填与启动扫描共用。路径选择——无记录、持久化水位非法、或水位
2202
- * 超前于日志最大 seq(事件 seq 空间被重编,如 dsh 0.1.5 V3 迁移)时从空状态全量
2203
- * 重放(超前水位会把后续全部实时事件封死在守卫之外,openTurnStart 永不清空、总
2204
- * 耗时无限增长);否则从持久化记账出发仅增量应用 `seq > watermarkSeq` 的事件。
2178
+ * 记账,宿主侧懒回填与启动扫描共用。路径选择——无记录、强制重放、持久化水位非法、
2179
+ * 或水位超前于日志最大 seq(事件 seq 空间被重编,如 dsh 0.1.5 V3 迁移)时从空状态
2180
+ * 全量重放(超前水位会把后续全部实时事件封死在守卫之外,openTurnStart 永不清空、
2181
+ * 总耗时无限增长);否则从持久化记账出发仅增量应用 `seq > watermarkSeq` 的事件。
2182
+ * 水位推进处同步记录 `watermarkTime`(水位处已覆盖事件的时刻,与实时登记按效果事件
2183
+ * 写入的口径互为保守——检测只要求该值不超过真实边界事件时刻)——宿主实时登记据此
2184
+ * 识别 seq 空间重编(低 seq 事件携带比水位更新的时刻,增量口径已失效)。
2205
2185
  * `closeOpenTurn`(宿主启动扫描):重放后仍存在的开放回合按日志最后事件时刻强制
2206
2186
  * 结算关闭——宿主重启后不存在仍在运行的回合,残留起点只会令总耗时无限增长;尾部
2207
2187
  * 未配对等待一并按同刻结算(不落到运行时长里)。
2208
2188
  */
2209
- function reconcileTurnStats(current, records, { closeOpenTurn = false } = {}) {
2189
+ function reconcileTurnStats(current, records, { closeOpenTurn = false, forceFresh = false } = {}) {
2210
2190
  const list = Array.isArray(records) ? records : [];
2211
2191
  let maxSeq = null;
2212
2192
  let lastTime = null;
@@ -2216,23 +2196,32 @@ function reconcileTurnStats(current, records, { closeOpenTurn = false } = {}) {
2216
2196
  const time = durationTime(eventOf(record)?.time);
2217
2197
  if (time !== null && (lastTime === null || time > lastTime)) lastTime = time;
2218
2198
  }
2219
- const fresh = !isRecord(current) || !Number.isFinite(current.watermarkSeq) || (maxSeq !== null && Number(current.watermarkSeq) > maxSeq);
2199
+ const fresh = forceFresh || !isRecord(current) || !Number.isFinite(current.watermarkSeq) || (maxSeq !== null && Number(current.watermarkSeq) > maxSeq);
2220
2200
  let state = emptyTurnStats();
2221
2201
  let watermarkSeq = null;
2202
+ let watermarkTime = null;
2203
+ // 水位推进:seq 更大即前推水位;事件时刻有效才更新 watermarkTime(无效不前推)。
2204
+ const advance = (record) => {
2205
+ const seq = Number(record?.seq);
2206
+ if (!Number.isFinite(seq) || (watermarkSeq !== null && seq <= watermarkSeq)) return;
2207
+ watermarkSeq = seq;
2208
+ const time = durationTime(eventOf(record)?.time);
2209
+ if (time !== null) watermarkTime = time;
2210
+ };
2222
2211
  if (fresh) {
2223
2212
  for (const record of list) {
2224
2213
  state = applyTurnEventToStats(state, record);
2225
- const seq = Number(record?.seq);
2226
- if (Number.isFinite(seq) && (watermarkSeq === null || seq > watermarkSeq)) watermarkSeq = seq;
2214
+ advance(record);
2227
2215
  }
2228
2216
  } else {
2229
2217
  state = turnStatsFrom(current);
2218
+ watermarkTime = state.watermarkTime;
2230
2219
  watermarkSeq = Number(current.watermarkSeq);
2231
2220
  for (const record of list) {
2232
2221
  const seq = Number(record?.seq);
2233
2222
  if (!Number.isFinite(seq) || seq <= watermarkSeq) continue;
2234
2223
  state = applyTurnEventToStats(state, record);
2235
- watermarkSeq = seq;
2224
+ advance(record);
2236
2225
  }
2237
2226
  }
2238
2227
  if (closeOpenTurn && state.openTurnStart !== null) {
@@ -2245,7 +2234,7 @@ function reconcileTurnStats(current, records, { closeOpenTurn = false } = {}) {
2245
2234
  state.openWaitId = null;
2246
2235
  state.waitedMs = null;
2247
2236
  }
2248
- return { ...state, watermarkSeq };
2237
+ return { ...state, watermarkSeq, watermarkTime };
2249
2238
  }
2250
2239
 
2251
2240
  /**
@@ -3035,9 +3024,11 @@ const CSS = `
3035
3024
  box-shadow: none;
3036
3025
  animation: none;
3037
3026
  }
3027
+ /* 会话卡标题(活动卡、子代理卡与最近卡共用 .dap-title)统一常规字重,不加粗
3028
+ (R-01-013/AC-09 及东家 2026-09-11 视觉反馈);无按卡类的字重覆盖。 */
3038
3029
  [data-dsh-activity-pane] .dap-title {
3039
3030
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
3040
- white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 700;
3031
+ white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 400;
3041
3032
  }
3042
3033
  /* 标题行最右侧的累计运行时长(R-01-020/AC-01):固定占位不参与标题挤压,
3043
3034
  标题过长时以自身省略号让位;色调弱于标题,不与状态点抢视觉。 */
@@ -3046,10 +3037,6 @@ const CSS = `
3046
3037
  color: #8a94a3; white-space: nowrap;
3047
3038
  }
3048
3039
  [data-dsh-activity-pane] .dap-total-time[hidden] { display: none; }
3049
- /* 最近历史卡标题降为常规字重:历史区不抢占视觉强调(R-01-013/AC-09)。 */
3050
- [data-dsh-activity-pane] .dap-card[data-kind="recent"] .dap-title {
3051
- font-weight: 400;
3052
- }
3053
3040
  /* 等待卡末行首行「类型胶囊」(R-01-002/AC-01、AC-02、AC-09、AC-13,C-043):圆底类型
3054
3041
  图标 + 类型文字,色相随等待类别(--dap-wait-color)——阻塞金/完成绿/错误红;
3055
3042
  胶囊为行内元素不自占满宽,随文字内容收缩。 */
@@ -5055,13 +5042,14 @@ function apply(ctx) {
5055
5042
  }
5056
5043
  }
5057
5044
 
5058
- /** 最近回合耗时 memo(等待卡与暂停子代理卡共用,R-01-009/AC-12、AC-15):快照/历史引用不变即命中缓存。 */
5059
- function memoTurnDuration(detail, detailSnapshot) {
5045
+ /** 最近回合耗时 memo(等待卡与暂停子代理卡共用,R-01-009/AC-12、AC-15):history 引用
5046
+ * 不变即命中缓存。busy 口径(起止差值扣除回合内阻塞等待),与标题行总耗时同口径,
5047
+ * 保证恒不大于累计值(R-01-020)。 */
5048
+ function memoTurnDuration(detail) {
5060
5049
  const history = detail.history ?? null;
5061
- if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
5062
- detail.memoTurnDurationSnapshotOf = detailSnapshot;
5050
+ if (detail.memoTurnDurationHistoryOf !== history) {
5063
5051
  detail.memoTurnDurationHistoryOf = history;
5064
- detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
5052
+ detail.memoTurnDuration = lastTurnDuration({ history });
5065
5053
  }
5066
5054
  return detail.memoTurnDuration ?? null;
5067
5055
  }
@@ -5966,7 +5954,7 @@ function apply(ctx) {
5966
5954
  }
5967
5955
  }
5968
5956
  if (entry.kind === "awaiting" && detail) {
5969
- entry.elapsedMs = memoTurnDuration(detail, detailSnapshot);
5957
+ entry.elapsedMs = memoTurnDuration(detail);
5970
5958
  }
5971
5959
  if (detail?.model) {
5972
5960
  entry.model = detail.model.model;
@@ -6038,7 +6026,7 @@ function apply(ctx) {
6038
6026
  // 非运行(暂停等待):冻结最后已知统计与最近回合耗时,progress 置空隐藏
6039
6027
  // 进度条(R-01-009/AC-15);刷新/无留存时回退当前列表投影。
6040
6028
  Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
6041
- entry.elapsedMs = detail ? memoTurnDuration(detail, detailSnapshot) : null;
6029
+ entry.elapsedMs = detail ? memoTurnDuration(detail) : null;
6042
6030
  entry.progress = null;
6043
6031
  }
6044
6032
  }
@@ -6061,13 +6049,13 @@ function apply(ctx) {
6061
6049
  recentTotal = recentCandidates.length;
6062
6050
  const recent = recentCandidates.slice(0, recentVisibleCount);
6063
6051
  recentHasMore = recent.length < recentTotal;
6064
- // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
6065
- // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
6052
+ // 最近卡统计复用运行卡的列表投影口径;耗时从已读 history 取最近完整回合的运行
6053
+ // 过程时长(busy 口径,与标题行总耗时一致,R-01-013/AC-12),缺边界时仅为当前
6054
+ // 可见历史卡安排一次既有 history 补读。
6066
6055
  const recentDurationFallbackIds = new Set();
6067
6056
  for (const entry of recent) {
6068
6057
  const detail = sessionDetailsById.get(entry.id);
6069
- const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
6070
- const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
6058
+ const elapsedMs = lastTurnDuration({ history: detail?.history ?? null });
6071
6059
  // 累计运行时长(R-01-020/AC-01):最近历史卡同为标题行右侧显示;历史卡无开放回合。
6072
6060
  applyTotalBusy(entry, now);
6073
6061
  const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-activity-pane",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -139,8 +139,8 @@ const steps = [
139
139
  "找一个发指令后 agent 运行了较久才结束的会话,待其落入最近历史区后,确认卡片第五行同时显示本地绝对日期时间(跨年份带年份)与相对活动时间,且绝对时间对应最后回复结束时刻(而非发指令时刻);历史区仍按该时刻从新到旧排序。刷新页面(冷会话)后确认时间先按宿主列表时间显示、数据到达后精化为回复结束时刻,相对时间会按分钟级推进。",
140
140
  // R-01-013/AC-12 最近历史卡保留最近回合统计
141
141
  "让一个已有完整回合的会话结束并经「移入历史」进入最近历史区,确认助手预览行之后、活动时间之前的倒数第二行仍显示最后回合的「tok/s · 缓存 · 输入 · 输出」可用统计,耗时固定在该行最右侧;缺失字段不造假且全部为空时不留空行。刷新页面后确认可从 native 列表投影与回合边界恢复。",
142
- // R-01-013/AC-09 最近卡标题常规字重
143
- "确认最近历史卡的会话标题为常规字重(不加粗),与活动卡的加粗标题形成明显区分。",
142
+ // R-01-013/AC-09 会话卡标题统一常规字重
143
+ "确认活动卡与最近历史卡的会话标题均为常规字重(不加粗)。",
144
144
  // R-01-013/AC-10 最近卡整体淡化
145
145
  "确认最近历史卡整体比活动卡明显更淡(不透明度更低),悬停时仍有高亮反馈。",
146
146
  // R-01-013/AC-11 最近卡轮廓可辨
package/scripts/check.mjs CHANGED
@@ -58,8 +58,7 @@ import {
58
58
  catalogModelEntries,
59
59
  lastTurnEndFromEvents,
60
60
  lastTurnEndFromTimings,
61
- lastTurnDurationFromEvents,
62
- lastTurnDurationFromTimings,
61
+ lastTurnBusyFromEvents,
63
62
  lastTurnDuration,
64
63
  applyTurnEventToStats,
65
64
  reconcileTurnStats,
@@ -1366,6 +1365,50 @@ assert.equal(normalizeBusyMs(Number.NaN), null, "NaN 归一为 null");
1366
1365
  assert.equal(next.openTurnStart, null, "R-01-020/AC-04 启动扫描后无残留开放回合");
1367
1366
  assert.equal(next.waitedMs, null, "R-01-020/AC-04 启动扫描后等待累计清零");
1368
1367
  }
1368
+ // 重编检测(R-01-020/AC-04):水位推进处记录 watermarkTime;增量重放同步更新。
1369
+ {
1370
+ const next = reconcileTurnStats(
1371
+ { busyMs: 500, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null, watermarkSeq: 2, watermarkTime: 600 },
1372
+ [
1373
+ { type: "turn/start", seq: 3, time: 10_000 },
1374
+ { type: "turn/end", seq: 4, time: 11_000 },
1375
+ ],
1376
+ );
1377
+ assert.equal(next.busyMs, 1_500, "R-01-020/AC-04 增量重放补齐缺口回合");
1378
+ assert.equal(next.watermarkTime, 11_000, "R-01-020/AC-04 水位推进处记录最后应用边界事件的时刻");
1379
+ }
1380
+ // 部分重叠的 seq 空间重编(水位 ≤ 日志最大 seq,既有水位超前检测不可达):forceFresh
1381
+ // 丢弃旧空间记账、从空状态全量重放,实时登记的重编检测据此自愈;存量记录(无
1382
+ // watermarkTime)同走该路径。
1383
+ {
1384
+ const forced = reconcileTurnStats(
1385
+ { busyMs: 100, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null, watermarkSeq: 2, watermarkTime: 600 },
1386
+ [
1387
+ { type: "turn/start", seq: 1, time: 10_000 },
1388
+ { type: "turn/end", seq: 2, time: 11_000 },
1389
+ { type: "turn/start", seq: 3, time: 11_500 },
1390
+ { type: "turn/end", seq: 4, time: 12_000 },
1391
+ ],
1392
+ { forceFresh: true },
1393
+ );
1394
+ assert.equal(forced.busyMs, 1_500, "R-01-020/AC-04 强制全量重放按新 seq 空间重算累计");
1395
+ assert.equal(forced.watermarkSeq, 4, "R-01-020/AC-04 强制全量重放后水位落在新空间");
1396
+ assert.equal(forced.watermarkTime, 12_000, "R-01-020/AC-04 强制全量重放后水位时刻为最后边界事件");
1397
+ }
1398
+ {
1399
+ const legacy = reconcileTurnStats(
1400
+ { busyMs: 100, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null, watermarkSeq: 2 },
1401
+ [
1402
+ { type: "turn/start", seq: 1, time: 10_000 },
1403
+ { type: "turn/end", seq: 2, time: 11_000 },
1404
+ { type: "tool/call", seq: 3, time: 11_500, data: { callId: "c1", name: "bash" } },
1405
+ ],
1406
+ { forceFresh: true },
1407
+ );
1408
+ assert.equal(legacy.busyMs, 1_000, "R-01-020/AC-04 存量记录(无 watermarkTime)强制重放后按新空间重算");
1409
+ assert.equal(legacy.watermarkSeq, 3, "R-01-020/AC-04 存量记录重放后水位覆盖全部事件(含无效果事件)");
1410
+ assert.equal(legacy.watermarkTime, 11_500, "R-01-020/AC-04 存量记录重放后补上水位时刻");
1411
+ }
1369
1412
  // R-01-013/AC-05:历史卡同时显示本地绝对日期时间与相对年龄,异常输入不制造虚假时间。
1370
1413
  const relativeMinute = 60_000;
1371
1414
  const relativeHour = 60 * relativeMinute;
@@ -2824,67 +2867,64 @@ assert.equal(
2824
2867
  2500,
2825
2868
  "turnTimings 取最大 endTime,忽略未结束回合",
2826
2869
  );
2827
- // ---- R-01-009/AC-12 等待卡保留最近完整回合耗时 ----
2870
+ // ---- R-01-009/AC-12 等待卡保留最近完整回合耗时(busy 口径,R-01-020 语义) ----
2828
2871
  assert.equal(
2829
- lastTurnDurationFromTimings(new Map([
2830
- [1, { startTime: 100, endTime: 900 }],
2831
- [2, { startTime: 1000 }],
2832
- [3, { startTime: 2000, endTime: 2500 }],
2833
- ])),
2834
- 500,
2835
- "turnTimings 取最近已结束回合的起止差值,不取更早回合或开放回合",
2836
- );
2837
- assert.equal(
2838
- lastTurnDurationFromTimings(new Map([[1, { startTime: 900, endTime: 100 }]])),
2839
- null,
2840
- "起点晚于终点时不生成虚假回合耗时",
2841
- );
2842
- assert.equal(lastTurnDurationFromTimings(new Map([[1, { startTime: 100 }]])), null, "无 endTime 不生成等待耗时");
2843
- assert.equal(
2844
- lastTurnDurationFromEvents([
2872
+ lastTurnBusyFromEvents([
2845
2873
  { event: { type: "turn/start", time: 100, data: { turn: 1 } } },
2846
2874
  { event: { type: "turn/end", time: 900, data: { turn: 1 } } },
2847
2875
  { event: { type: "turn/start", time: 1000, data: { turn: 2 } } },
2848
2876
  { event: { type: "turn/end", time: 2500, data: { turn: 2 } } },
2849
2877
  ]),
2850
2878
  1500,
2851
- "history 按同一 turn 配对并取最近已结束回合的耗时",
2879
+ "history 取最近已结束回合的起止差值",
2852
2880
  );
2853
2881
  assert.equal(
2854
- lastTurnDurationFromEvents([
2855
- { event: { type: "turn/start", time: 900, data: { turn: 1 } } },
2856
- { event: { type: "turn/end", time: 100, data: { turn: 1 } } },
2882
+ lastTurnBusyFromEvents([
2883
+ { event: { type: "turn/start", time: 1000, data: { turn: 1 } } },
2884
+ { event: { type: "tool/call", time: 2000, data: { callId: "c1", name: "ask_user_question" } } },
2885
+ { event: { type: "tool/result", time: 7000, data: { message: { source: { kind: "tool", callId: "c1" } } } } },
2886
+ { event: { type: "turn/end", time: 8000, data: { turn: 1 } } },
2857
2887
  ]),
2858
- null,
2859
- "history 起止逆序时不生成虚假回合耗时",
2888
+ 2000,
2889
+ "最近回合耗时扣除回合内提问等待(busy 口径,与总耗时一致)",
2860
2890
  );
2861
2891
  assert.equal(
2862
- lastTurnDurationFromEvents([
2863
- { event: { type: "turn/start", time: null, data: { turn: 1 } } },
2864
- { event: { type: "turn/end", time: 100, data: { turn: 1 } } },
2892
+ lastTurnBusyFromEvents([
2893
+ { event: { type: "turn/start", time: 1000, data: { turn: 1 } } },
2894
+ { event: { type: "approval/asked", time: 2000, data: { id: "a1" } } },
2895
+ { event: { type: "approval/decided", time: 6000, data: { id: "a1" } } },
2896
+ { event: { type: "turn/end", time: 7000, data: { turn: 1 } } },
2897
+ ]),
2898
+ 2000,
2899
+ "最近回合耗时扣除回合内审批等待",
2900
+ );
2901
+ assert.equal(
2902
+ lastTurnBusyFromEvents([
2903
+ { event: { type: "turn/start", time: 1000, data: { turn: 1 } } },
2904
+ { event: { type: "turn/end", time: 900, data: { turn: 1 } } },
2865
2905
  ]),
2866
2906
  null,
2867
- "history 起点缺失时不把 null 当作时间零点",
2907
+ "起止逆序时不生成虚假回合耗时",
2868
2908
  );
2869
2909
  assert.equal(
2870
- lastTurnDurationFromTimings(new Map([[1, { startTime: Number.NaN, endTime: 100 }]])),
2910
+ lastTurnBusyFromEvents([
2911
+ { event: { type: "turn/start", time: null, data: { turn: 1 } } },
2912
+ { event: { type: "turn/end", time: 100, data: { turn: 1 } } },
2913
+ ]),
2871
2914
  null,
2872
- "turnTimings 起点非有限时不生成等待耗时",
2915
+ "起点缺失时不把 null 当作时间零点",
2873
2916
  );
2874
2917
  assert.equal(
2875
- lastTurnDuration({
2876
- turnTimings: new Map([
2877
- [1, { startTime: 100, endTime: 900 }],
2878
- [2, { endTime: 2500 }],
2879
- ]),
2880
- history: [],
2881
- }),
2882
- 800,
2883
- "turnTimings 最新孤立 end 不得与更早完整回合错配",
2918
+ lastTurnBusyFromEvents([
2919
+ { event: { type: "turn/start", time: 1000, data: { turn: 1 } } },
2920
+ { event: { type: "tool/call", time: 2000, data: { callId: "c1", name: "ask_user_question" } } },
2921
+ { event: { type: "turn/end", time: 5000, data: { turn: 1 } } },
2922
+ ]),
2923
+ 1000,
2924
+ "turn/end 强制结算未配对等待:等待尾部不计入最近回合耗时",
2884
2925
  );
2885
2926
  assert.equal(
2886
2927
  lastTurnDuration({
2887
- turnTimings: new Map(),
2888
2928
  history: [
2889
2929
  { event: { type: "turn/start", time: 100, data: { turn: 1 } } },
2890
2930
  { event: { type: "turn/end", time: 900, data: { turn: 1 } } },
@@ -2892,24 +2932,10 @@ assert.equal(
2892
2932
  ],
2893
2933
  }),
2894
2934
  800,
2895
- "history 最新孤立 end 不得与更早完整回合错配",
2896
- );
2897
- assert.equal(
2898
- lastTurnDuration({
2899
- turnTimings: new Map([[1, { startTime: 100, endTime: 900 }]]),
2900
- history: [
2901
- { event: { type: "turn/start", time: 1000, data: { turn: 2 } } },
2902
- { event: { type: "turn/end", time: 1300, data: { turn: 2 } } },
2903
- ],
2904
- }),
2905
- 300,
2906
- "多来源时按最近结束时刻选择同一最新回合耗时",
2907
- );
2908
- assert.equal(
2909
- lastTurnDuration({ turnTimings: new Map([[1, { startTime: 100, endTime: 900 }]]), history: [] }),
2910
- 800,
2911
- "无 history 时从 turnTimings 提取回合耗时",
2935
+ "最新孤立 end 不得与更早完整回合错配",
2912
2936
  );
2937
+ assert.equal(lastTurnDuration({ history: [] }), null, "空 history 无回合耗时");
2938
+ assert.equal(lastTurnDuration({ history: null }), null, "history 缺失回退 null");
2913
2939
  const refineSnap = {
2914
2940
  ids: ["sTurn", "sPrompt", "sNone"],
2915
2941
  byId: {
@@ -3836,9 +3862,9 @@ assert.ok(bundle.includes("dataset.traceKey"), "同一流程节点复用 DOM,
3836
3862
  assert.ok(!bundle.includes(".dap-trace-item[data-status=\"running\"]::before {\n animation: none !important;"), "降低动效设置不关闭运行点脉冲");
3837
3863
 
3838
3864
  // R-01-013/AC-09
3839
- // 最近历史卡标题降为常规字重(不加粗),活动卡标题保持加粗。
3840
- assert.ok(bundle.includes('[data-kind="recent"] .dap-title {\n font-weight: 400;'), "最近历史卡标题使用常规字重(不加粗)");
3841
- assert.ok(bundle.includes("white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 700;"), "活动卡标题保持加粗 700");
3865
+ // 全部会话卡标题(活动卡、子代理卡、最近卡)统一常规字重(不加粗),无按卡类覆盖。
3866
+ assert.ok(bundle.includes("white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 400;"), "会话卡标题使用常规字重(不加粗)");
3867
+ assert.ok(!bundle.includes('[data-kind="recent"] .dap-title {'), "最近卡标题不再有独立字重覆盖(与活动卡共用常规字重)");
3842
3868
 
3843
3869
  // R-01-013/AC-10
3844
3870
  // 最近历史卡整体不透明度低于活动卡,弱化历史区视觉强调。
package/src/client.mjs CHANGED
@@ -437,9 +437,11 @@ const CSS = `
437
437
  box-shadow: none;
438
438
  animation: none;
439
439
  }
440
+ /* 会话卡标题(活动卡、子代理卡与最近卡共用 .dap-title)统一常规字重,不加粗
441
+ (R-01-013/AC-09 及东家 2026-09-11 视觉反馈);无按卡类的字重覆盖。 */
440
442
  [data-dsh-activity-pane] .dap-title {
441
443
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
442
- white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 700;
444
+ white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 400;
443
445
  }
444
446
  /* 标题行最右侧的累计运行时长(R-01-020/AC-01):固定占位不参与标题挤压,
445
447
  标题过长时以自身省略号让位;色调弱于标题,不与状态点抢视觉。 */
@@ -448,10 +450,6 @@ const CSS = `
448
450
  color: #8a94a3; white-space: nowrap;
449
451
  }
450
452
  [data-dsh-activity-pane] .dap-total-time[hidden] { display: none; }
451
- /* 最近历史卡标题降为常规字重:历史区不抢占视觉强调(R-01-013/AC-09)。 */
452
- [data-dsh-activity-pane] .dap-card[data-kind="recent"] .dap-title {
453
- font-weight: 400;
454
- }
455
453
  /* 等待卡末行首行「类型胶囊」(R-01-002/AC-01、AC-02、AC-09、AC-13,C-043):圆底类型
456
454
  图标 + 类型文字,色相随等待类别(--dap-wait-color)——阻塞金/完成绿/错误红;
457
455
  胶囊为行内元素不自占满宽,随文字内容收缩。 */
@@ -2457,13 +2455,14 @@ function apply(ctx) {
2457
2455
  }
2458
2456
  }
2459
2457
 
2460
- /** 最近回合耗时 memo(等待卡与暂停子代理卡共用,R-01-009/AC-12、AC-15):快照/历史引用不变即命中缓存。 */
2461
- function memoTurnDuration(detail, detailSnapshot) {
2458
+ /** 最近回合耗时 memo(等待卡与暂停子代理卡共用,R-01-009/AC-12、AC-15):history 引用
2459
+ * 不变即命中缓存。busy 口径(起止差值扣除回合内阻塞等待),与标题行总耗时同口径,
2460
+ * 保证恒不大于累计值(R-01-020)。 */
2461
+ function memoTurnDuration(detail) {
2462
2462
  const history = detail.history ?? null;
2463
- if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
2464
- detail.memoTurnDurationSnapshotOf = detailSnapshot;
2463
+ if (detail.memoTurnDurationHistoryOf !== history) {
2465
2464
  detail.memoTurnDurationHistoryOf = history;
2466
- detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
2465
+ detail.memoTurnDuration = lastTurnDuration({ history });
2467
2466
  }
2468
2467
  return detail.memoTurnDuration ?? null;
2469
2468
  }
@@ -3368,7 +3367,7 @@ function apply(ctx) {
3368
3367
  }
3369
3368
  }
3370
3369
  if (entry.kind === "awaiting" && detail) {
3371
- entry.elapsedMs = memoTurnDuration(detail, detailSnapshot);
3370
+ entry.elapsedMs = memoTurnDuration(detail);
3372
3371
  }
3373
3372
  if (detail?.model) {
3374
3373
  entry.model = detail.model.model;
@@ -3440,7 +3439,7 @@ function apply(ctx) {
3440
3439
  // 非运行(暂停等待):冻结最后已知统计与最近回合耗时,progress 置空隐藏
3441
3440
  // 进度条(R-01-009/AC-15);刷新/无留存时回退当前列表投影。
3442
3441
  Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
3443
- entry.elapsedMs = detail ? memoTurnDuration(detail, detailSnapshot) : null;
3442
+ entry.elapsedMs = detail ? memoTurnDuration(detail) : null;
3444
3443
  entry.progress = null;
3445
3444
  }
3446
3445
  }
@@ -3463,13 +3462,13 @@ function apply(ctx) {
3463
3462
  recentTotal = recentCandidates.length;
3464
3463
  const recent = recentCandidates.slice(0, recentVisibleCount);
3465
3464
  recentHasMore = recent.length < recentTotal;
3466
- // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
3467
- // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
3465
+ // 最近卡统计复用运行卡的列表投影口径;耗时从已读 history 取最近完整回合的运行
3466
+ // 过程时长(busy 口径,与标题行总耗时一致,R-01-013/AC-12),缺边界时仅为当前
3467
+ // 可见历史卡安排一次既有 history 补读。
3468
3468
  const recentDurationFallbackIds = new Set();
3469
3469
  for (const entry of recent) {
3470
3470
  const detail = sessionDetailsById.get(entry.id);
3471
- const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
3472
- const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
3471
+ const elapsedMs = lastTurnDuration({ history: detail?.history ?? null });
3473
3472
  // 累计运行时长(R-01-020/AC-01):最近历史卡同为标题行右侧显示;历史卡无开放回合。
3474
3473
  applyTotalBusy(entry, now);
3475
3474
  const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
package/src/core.mjs CHANGED
@@ -1979,58 +1979,30 @@ function durationTime(value) {
1979
1979
  return Number.isFinite(time) ? time : null;
1980
1980
  }
1981
1981
 
1982
- /** 从 history 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1983
- function lastTurnDurationCandidateFromEvents(events) {
1984
- const starts = new Map();
1982
+ /**
1983
+ * 从 history 重放回合/等待边界事件,提取最近一个已结束回合的运行过程耗时(busy 口径:
1984
+ * 起止差值扣除回合内阻塞等待,与 R-01-020 累计口径一致,恒不大于该回合墙钟时长)。
1985
+ * 回合起止不完整、时间逆序或回合运行段不为正时忽略该回合;无可得回合返回 null。
1986
+ */
1987
+ export function lastTurnBusyFromEvents(events) {
1988
+ let state = emptyTurnStats();
1985
1989
  let latest = null;
1986
1990
  for (const entry of Array.isArray(events) ? events : []) {
1987
- const event = eventOf(entry);
1988
- const turn = Number(event?.data?.turn);
1989
- const time = durationTime(event?.time);
1990
- if (!Number.isFinite(turn) || time === null) continue;
1991
- if (event.type === "turn/start") {
1992
- starts.set(turn, time);
1993
- continue;
1994
- }
1995
- if (event.type !== "turn/end") continue;
1996
- const start = starts.get(turn);
1997
- if (start === undefined || time < start) continue;
1998
- if (latest === null || time > latest.end) latest = { end: time, duration: time - start };
1991
+ const time = durationTime(eventOf(entry)?.time);
1992
+ const prev = state;
1993
+ state = applyTurnEventToStats(state, entry);
1994
+ if (prev.openTurnStart === null || state.openTurnStart !== null) continue;
1995
+ // 能走到回合闭合的必为时刻有效的 turn/end(applyTurnEventToStats 对无效时刻无效果)。
1996
+ const activeMs = (state.busyMs ?? 0) - (prev.busyMs ?? 0);
1997
+ if (activeMs <= 0) continue;
1998
+ latest = { end: time, duration: activeMs };
1999
1999
  }
2000
- return latest;
2001
- }
2002
-
2003
- /** 从 turnTimings 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
2004
- function lastTurnDurationCandidateFromTimings(turnTimings) {
2005
- if (!(turnTimings instanceof Map)) return null;
2006
- let latest = null;
2007
- for (const timing of turnTimings.values()) {
2008
- const start = durationTime(timing?.startTime);
2009
- const end = durationTime(timing?.endTime);
2010
- if (start === null || end === null || end < start) continue;
2011
- if (latest === null || end > latest.end) latest = { end, duration: end - start };
2012
- }
2013
- return latest;
2014
- }
2015
-
2016
- /** 从 history 提取最近完整回合的固定耗时;回合起止不完整或逆序时忽略该回合。 */
2017
- export function lastTurnDurationFromEvents(events) {
2018
- return lastTurnDurationCandidateFromEvents(events)?.duration ?? null;
2019
- }
2020
-
2021
- /** 从 turnTimings 提取最近完整回合的固定耗时;全部回合未结束或无效时返回 null。 */
2022
- export function lastTurnDurationFromTimings(turnTimings) {
2023
- return lastTurnDurationCandidateFromTimings(turnTimings)?.duration ?? null;
2000
+ return latest?.duration ?? null;
2024
2001
  }
2025
2002
 
2026
- /** 在快照与 history 中按最近结束时刻选择同一最新完整回合的固定耗时。 */
2027
- export function lastTurnDuration({ turnTimings = null, history = [] } = {}) {
2028
- const candidates = [lastTurnDurationCandidateFromTimings(turnTimings), lastTurnDurationCandidateFromEvents(history)].filter(Boolean);
2029
- let latest = null;
2030
- for (const candidate of candidates) {
2031
- if (latest === null || candidate.end > latest.end) latest = candidate;
2032
- }
2033
- return latest?.duration ?? null;
2003
+ /** 最近完整回合的固定耗时(busy 口径):从 history 事件重放派生。 */
2004
+ export function lastTurnDuration({ history = [] } = {}) {
2005
+ return lastTurnBusyFromEvents(history);
2034
2006
  }
2035
2007
 
2036
2008
  /** ask_user_question 工具名:提问/计划审查等待的开启边界(tool/call)与配对结算(tool/result)。 */
@@ -2046,7 +2018,7 @@ export function isBusyBoundaryEvent(type) {
2046
2018
 
2047
2019
  /** 空记账状态(无任何有效计时)。 */
2048
2020
  export function emptyTurnStats() {
2049
- return { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null };
2021
+ return { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null, watermarkTime: null };
2050
2022
  }
2051
2023
 
2052
2024
  /** 从任意来源拷贝记账状态:缺失字段归一为 null(旧记录/部分记录兼容)。 */
@@ -2058,10 +2030,14 @@ export function turnStatsFrom(state) {
2058
2030
  openWaitKind: state?.openWaitKind ?? null,
2059
2031
  openWaitId: state?.openWaitId ?? null,
2060
2032
  waitedMs: state?.waitedMs ?? null,
2033
+ watermarkTime: state?.watermarkTime ?? null,
2061
2034
  };
2062
2035
  }
2063
2036
 
2064
- /** 记账状态逐字段相等(不含 watermarkSeq——宿主侧无效果事件跳过落盘的判定)。 */
2037
+ /**
2038
+ * 记账状态逐字段相等(不含 watermarkSeq——它不是状态字段,由宿主侧随写入一并落盘;
2039
+ * watermarkTime 为水位处已覆盖事件的时刻,参与相等判定以让补写该字段的收敛写入生效)。
2040
+ */
2065
2041
  export function turnStatsEqual(a, b) {
2066
2042
  return (
2067
2043
  a === b ||
@@ -2070,7 +2046,8 @@ export function turnStatsEqual(a, b) {
2070
2046
  (a?.openWaitStart ?? null) === (b?.openWaitStart ?? null) &&
2071
2047
  (a?.openWaitKind ?? null) === (b?.openWaitKind ?? null) &&
2072
2048
  (a?.openWaitId ?? null) === (b?.openWaitId ?? null) &&
2073
- (a?.waitedMs ?? null) === (b?.waitedMs ?? null))
2049
+ (a?.waitedMs ?? null) === (b?.waitedMs ?? null) &&
2050
+ (a?.watermarkTime ?? null) === (b?.watermarkTime ?? null))
2074
2051
  );
2075
2052
  }
2076
2053
 
@@ -2192,15 +2169,18 @@ export function totalBusyDisplayMs({ busyMs = null, openTurnStart = null, waited
2192
2169
 
2193
2170
  /**
2194
2171
  * 回合统计记账的统一收敛(R-01-020/AC-04、AC-05):对全会话事件列表重放出下一份
2195
- * 记账,宿主侧懒回填与启动扫描共用。路径选择——无记录、持久化水位非法、或水位
2196
- * 超前于日志最大 seq(事件 seq 空间被重编,如 dsh 0.1.5 V3 迁移)时从空状态全量
2197
- * 重放(超前水位会把后续全部实时事件封死在守卫之外,openTurnStart 永不清空、总
2198
- * 耗时无限增长);否则从持久化记账出发仅增量应用 `seq > watermarkSeq` 的事件。
2172
+ * 记账,宿主侧懒回填与启动扫描共用。路径选择——无记录、强制重放、持久化水位非法、
2173
+ * 或水位超前于日志最大 seq(事件 seq 空间被重编,如 dsh 0.1.5 V3 迁移)时从空状态
2174
+ * 全量重放(超前水位会把后续全部实时事件封死在守卫之外,openTurnStart 永不清空、
2175
+ * 总耗时无限增长);否则从持久化记账出发仅增量应用 `seq > watermarkSeq` 的事件。
2176
+ * 水位推进处同步记录 `watermarkTime`(水位处已覆盖事件的时刻,与实时登记按效果事件
2177
+ * 写入的口径互为保守——检测只要求该值不超过真实边界事件时刻)——宿主实时登记据此
2178
+ * 识别 seq 空间重编(低 seq 事件携带比水位更新的时刻,增量口径已失效)。
2199
2179
  * `closeOpenTurn`(宿主启动扫描):重放后仍存在的开放回合按日志最后事件时刻强制
2200
2180
  * 结算关闭——宿主重启后不存在仍在运行的回合,残留起点只会令总耗时无限增长;尾部
2201
2181
  * 未配对等待一并按同刻结算(不落到运行时长里)。
2202
2182
  */
2203
- export function reconcileTurnStats(current, records, { closeOpenTurn = false } = {}) {
2183
+ export function reconcileTurnStats(current, records, { closeOpenTurn = false, forceFresh = false } = {}) {
2204
2184
  const list = Array.isArray(records) ? records : [];
2205
2185
  let maxSeq = null;
2206
2186
  let lastTime = null;
@@ -2210,23 +2190,32 @@ export function reconcileTurnStats(current, records, { closeOpenTurn = false } =
2210
2190
  const time = durationTime(eventOf(record)?.time);
2211
2191
  if (time !== null && (lastTime === null || time > lastTime)) lastTime = time;
2212
2192
  }
2213
- const fresh = !isRecord(current) || !Number.isFinite(current.watermarkSeq) || (maxSeq !== null && Number(current.watermarkSeq) > maxSeq);
2193
+ const fresh = forceFresh || !isRecord(current) || !Number.isFinite(current.watermarkSeq) || (maxSeq !== null && Number(current.watermarkSeq) > maxSeq);
2214
2194
  let state = emptyTurnStats();
2215
2195
  let watermarkSeq = null;
2196
+ let watermarkTime = null;
2197
+ // 水位推进:seq 更大即前推水位;事件时刻有效才更新 watermarkTime(无效不前推)。
2198
+ const advance = (record) => {
2199
+ const seq = Number(record?.seq);
2200
+ if (!Number.isFinite(seq) || (watermarkSeq !== null && seq <= watermarkSeq)) return;
2201
+ watermarkSeq = seq;
2202
+ const time = durationTime(eventOf(record)?.time);
2203
+ if (time !== null) watermarkTime = time;
2204
+ };
2216
2205
  if (fresh) {
2217
2206
  for (const record of list) {
2218
2207
  state = applyTurnEventToStats(state, record);
2219
- const seq = Number(record?.seq);
2220
- if (Number.isFinite(seq) && (watermarkSeq === null || seq > watermarkSeq)) watermarkSeq = seq;
2208
+ advance(record);
2221
2209
  }
2222
2210
  } else {
2223
2211
  state = turnStatsFrom(current);
2212
+ watermarkTime = state.watermarkTime;
2224
2213
  watermarkSeq = Number(current.watermarkSeq);
2225
2214
  for (const record of list) {
2226
2215
  const seq = Number(record?.seq);
2227
2216
  if (!Number.isFinite(seq) || seq <= watermarkSeq) continue;
2228
2217
  state = applyTurnEventToStats(state, record);
2229
- watermarkSeq = seq;
2218
+ advance(record);
2230
2219
  }
2231
2220
  }
2232
2221
  if (closeOpenTurn && state.openTurnStart !== null) {
@@ -2239,7 +2228,7 @@ export function reconcileTurnStats(current, records, { closeOpenTurn = false } =
2239
2228
  state.openWaitId = null;
2240
2229
  state.waitedMs = null;
2241
2230
  }
2242
- return { ...state, watermarkSeq };
2231
+ return { ...state, watermarkSeq, watermarkTime };
2243
2232
  }
2244
2233
 
2245
2234
  /**
package/src/host.mjs CHANGED
@@ -59,7 +59,9 @@ const domainSpec = defineDomain({
59
59
  * openTurnStart 当前开放回合起点(无开放回合为 null)、openWaitStart/openWaitKind 当前
60
60
  * 未配对等待区间(回合内阻塞等待:approval/question)、waitedMs 本回合已配对等待时长
61
61
  * 累计、watermarkSeq 实时登记已覆盖的最大事件 seq(回填与实时登记以水位衔接,seq ≤
62
- * watermark 的事件不重复应用)。全部字段可选/可空。 */
62
+ * watermark 的事件不重复应用)、watermarkTime 水位处事件的时刻(实时登记据此识别 seq
63
+ * 空间重编:低 seq 事件携带比水位更新的时刻,增量口径失效,须全量重放)。全部字段
64
+ * 可选/可空。 */
63
65
  const turnStatRecord = z.object({
64
66
  busyMs: z.number().nullable().optional(),
65
67
  openTurnStart: z.number().nullable().optional(),
@@ -68,6 +70,7 @@ const turnStatRecord = z.object({
68
70
  openWaitId: z.string().nullable().optional(),
69
71
  waitedMs: z.number().nullable().optional(),
70
72
  watermarkSeq: z.number().nullable().optional(),
73
+ watermarkTime: z.number().nullable().optional(),
71
74
  })
72
75
 
73
76
  /** 独立 domain:dsh-storage-domain 无迁移机制(version 不同在 open 时拒绝),新增表落新 domain,
@@ -122,8 +125,13 @@ export function apply(ctx) {
122
125
  resolveDomain()
123
126
  // 启动扫描(R-01-020/AC-04):宿主重启后不存在仍在运行的回合——残留开放回合的
124
127
  // 记账按日志最后事件时刻强制结算关闭,否则总耗时按 now − openTurnStart 无限增长。
128
+ // 无 watermarkTime 的存量记录(本字段引入前写入)强制全量重放复核一次——无论重编
129
+ // 后日志 seq 是否已超越旧水位(部分重叠重编)都能收敛,重写后带上该字段、不再
130
+ // 重复扫描;此后重编由实时登记的重编检测承接。
125
131
  for (const [id, record] of turnStats.entries()) {
126
- if (record?.openTurnStart != null) ensureTurnStatsFresh(id, { closeOpenTurn: true })
132
+ const closeOpenTurn = record?.openTurnStart != null
133
+ const legacy = record?.watermarkTime == null
134
+ if (closeOpenTurn || legacy) ensureTurnStatsFresh(id, { closeOpenTurn, forceFresh: legacy })
127
135
  }
128
136
  })
129
137
 
@@ -159,10 +167,17 @@ export function apply(ctx) {
159
167
  * reconcileTurnStats 收敛——无记录、水位非法或水位超前于日志最大 seq(事件 seq 空间
160
168
  * 被重编,如 dsh 0.1.5 V3 迁移)的会话自空状态全量重放;已有记录会话仅增量应用
161
169
  * seq > watermarkSeq 的事件(宿主离线期间经其它入口发生的回合),以水位衔接保证
162
- * 不重复、不遗漏。closeOpenTurn(启动扫描):重放后仍开放的回合按日志最后事件
163
- * 时刻强制结算。读取失败保留已有记账,随下次请求重试;会话不存在/已删除时静默跳过。 */
164
- function ensureTurnStatsFresh(id, { closeOpenTurn = false } = {}) {
165
- if (backfills.has(id)) return backfills.get(id)
170
+ * 不重复、不遗漏。forceFresh:调用侧已证实水位口径失效(实时登记识别出 seq 空间
171
+ * 重编,或存量记录尚未携带 watermarkTime)时丢弃已有记账、强制从空状态全量重放;
172
+ * 在途回填不得吞掉该选项——命中时在其完成后补一次强制重放。closeOpenTurn(启动
173
+ * 扫描):重放后仍开放的回合按日志最后事件时刻强制结算。读取失败保留已有记账,
174
+ * 随下次请求重试;会话不存在/已删除时静默跳过。 */
175
+ function ensureTurnStatsFresh(id, { closeOpenTurn = false, forceFresh = false } = {}) {
176
+ if (backfills.has(id)) {
177
+ const inflight = backfills.get(id)
178
+ if (!forceFresh) return inflight
179
+ return inflight.then(() => ensureTurnStatsFresh(id, { closeOpenTurn, forceFresh: true }))
180
+ }
166
181
  const promise = new Promise((resolve) => {
167
182
  ctx.inject(['sessionQuery'], (sessionQuery) => {
168
183
  resolve(sessionQuery)
@@ -170,13 +185,16 @@ export function apply(ctx) {
170
185
  }).then(async (sessionQuery) => {
171
186
  try {
172
187
  await domainReady
173
- const current = turnStats.get(id) ?? null
188
+ const current = forceFresh ? null : turnStats.get(id) ?? null
174
189
  const records = await sessionQuery.listEvents(id)
175
190
  const next = reconcileTurnStats(current, Array.isArray(records) ? records : [], { closeOpenTurn })
176
191
  const changed =
177
- current === null || next.watermarkSeq !== (current.watermarkSeq ?? null) || !turnStatsEqual(next, current)
192
+ forceFresh ||
193
+ current === null ||
194
+ next.watermarkSeq !== (current.watermarkSeq ?? null) ||
195
+ !turnStatsEqual(next, current)
178
196
  if (changed) {
179
- await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: next.watermarkSeq })
197
+ await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: next.watermarkSeq, watermarkTime: next.watermarkTime })
180
198
  broadcastBusy()
181
199
  }
182
200
  } catch (error) {
@@ -265,15 +283,31 @@ export function apply(ctx) {
265
283
  const inflight = backfills.get(id)
266
284
  if (inflight) await inflight
267
285
  }
268
- const current = turnStats.get(id)
269
- if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) return
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
+ // 正常增量路径应用。
294
+ 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
+ }
270
301
  const next = applyTurnEventToStats(turnStatsFrom(current), event)
271
302
  if (turnStatsEqual(next, current)) {
272
303
  // 无效果事件(非提问工具调用、未配对的结算事件等):不落盘不广播,
273
304
  // 水位不前移——重放路径对同类事件同样不改变记账状态,口径一致。
274
305
  return
275
306
  }
276
- await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: seq })
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 })
277
311
  broadcastBusy()
278
312
  } catch (error) {
279
313
  ctx.logger?.warn?.(`dsh-activity-pane: 回合统计登记失败(${id}): ${String(error)}`)