dsh-token-total 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,7 +60,7 @@ DSH(DeepSeek Harness)Web 插件:**Token 用量统计看板** + **单会话
60
60
  - 两张卡都由会话明细轮询驱动(每 1.5 秒一次),不额外起计时器;旧宿主只提供其中一份数据时,另一张卡显示空态而非整组消失,两份都缺时整组不渲染。窄视口下自动上下堆叠。
61
61
 
62
62
  - 浅色页面两行统计卡:第一行请求数量(附失败数)、总 tokens(附精确值)、缓存命中率(附命中 token)、token 速度(附平均首 token);第二行输入、输出、缓存读取、缓存写入四个 token 桶。不显示费用。
63
- - 子代理明细使用每个子代理的**真实名称**(取自子代理标签,缺失时回退到会话标题、工作目录或会话 id)。
63
+ - 子代理明细与请求记录使用每个子代理的**真实名称**:优先子代理自身标签,再取最新非空会话标题;旧 CLI 记录缺少标题时,读取本次会话树中实际父会话的 `subagent/catalog` 任务标签,最后才回退到日志头名称、工作目录或会话 id。只使用各日志的自有事件,不把继承的父标题/目录当作自身名称,不改写历史日志,也不改变 Token 统计。若直接打开旧子会话、实际父会话不在本次读取树中,则保留原有回退名称。
64
64
  - 中部为等宽、等高的 **子代理明细** 与 **按模型明细**;窄视口自动堆叠。使用固定列布局而非横向滚动,长名称省略并可悬浮查看全名,token 数同样可悬浮查看精确值。按模型明细的名称使用模型选择器的**显示名称**(路由 id 保留在悬浮提示中,无法解析时回退为 id);当多行显示名称相同(忽略大小写与分隔符)时,在名称下方用灰色小字标明各自供应商以便区分。
65
65
  - 请求记录按**轮**折叠(压缩调用归入独立分组),**默认全部折叠**;展开某一轮可查看该轮每一次请求的时间、模型、tokens、命中率、状态与入 / 出 / 读 / 写明细,支持单组展开和全部展开 / 收起。失败尝试、实际重试、压缩调用纳入统计;仅继承的父会话历史不重复计算。
66
66
  - 页面每 1.5 秒串行读取当前会话明细,切换或离开 tab 会取消请求和计时器。统计含本会话及所有后代的自有历史日志,缺失的首 token / 速度显示 `—`,读取错误保留上次成功结果。
@@ -88,7 +88,9 @@ dsh plugin --profile <profile> add "file:D:\work\dsh-token-total"
88
88
  - **事件来源**:`sessionPersistence` 的会话日志。兼容 `assistant/message` 的顶层 `usage`,也读取消息及失败 `assistant/attempt` 的原始流式用量;同一次请求以最后一份用量为准,修正值按差额更新。插件订阅 `session/event` 实时增量累加,同时一次性回扫已有历史会话;`llm/retry-started` 会清空当前 `(turn, step)` 槽位,因此失败尝试和后续重试分别计入,不重复累加同一次请求的多份报告。
89
89
  - **本地持久化**:聚合结果写入 storage-domain 的 `token_total_history` 域(`$DSH_HOME/storages/` 下),纯本地、无外部依赖。回扫游标按会话记录已消费的事件序号,重启后从断点继续。
90
90
  - **上下文与花费估算**:两张卡都从该会话自身的日志重放「下一次请求会带上什么」——被 `compaction/prune` / `compaction/summary` 遮蔽的事件剔除、压缩摘要按注入记忆回放、工具目录取最近一次 `request/header`——再按字符类估算 token(ASCII 约 4 字符 / token,CJK 约 1 token / 字符),所以分桶上的数字都是近似值。左卡的真实占用取最近一次回答上报的 `totalTokens − outputTokens`;仅当两者相差超过 15%(说明上游有压缩或裁剪,日志里的历史比模型实际收到的多)时,才多出一行说明,并把「已用上下文」改用供应商实测值。右卡的分桶是「每次请求重放内容之和」,圆环中心的总量与缓存命中率直接取自 `usage` 记录,保持精确。
91
- - **单会话统计**:不走聚合存储(那里没有会话维度),而是按需重放该会话及全部子孙会话的自有日志;已加载会话优先使用实时事件快照,避免持久化缓冲造成漏算。结果缓存 4 秒,任意会话的新事件都会使缓存失效,包含此前尚未发现的子会话。前端从首次读取起每 4 秒刷新,显示时使用原生实时主会话用量、主会话压缩调用用量与子树用量之和,胶囊与弹窗共用同一口径;切换会话时立即清除旧会话读数。读取失败不会把部分子树当作成功结果覆盖已有读数。
91
+ - **单会话统计**:不走聚合存储(那里没有会话维度),而是按需重放该会话及全部子孙会话的自有日志;已加载会话优先使用实时事件快照,避免持久化缓冲造成漏算。日志结果返回后以主会话与全部子树的自有用量为准,压缩调用已包含其中,胶囊与弹窗共用同一口径;尚未获得日志结果时回退到原生主会话投影。
92
+ - **会话统计缓存**:完整结果使用最多 64 项的 LRU 缓存,60 秒到期且命中不续期。新事件只失效发生变化的会话及相关祖先,支持尚未出现在持久化目录中的子/孙会话;父链信息不足时保守失效,避免漏算。没有事件订阅接口的旧宿主仍每 4 秒重新校验。同一会话树、同一数据源的普通并发请求共用一次计算;显式取消信号保持独立。失败、取消或不完整的计算均不缓存,后续请求可以重试缺失的日志。
93
+ - **会话统计刷新**:前台首次读取,并在每次请求完成后等待 4 秒再轮询;会话签名变化时即时刷新,慢请求期间的变化合并为一次追刷。页面隐藏时暂停后续轮询,恢复可见时立即刷新;切换或卸载会取消浏览器请求,并丢弃迟到的旧响应。相同结果不重复写入 React 状态。根日志或目录读取失败保留上次读数;部分子树不可读时显示可读用量与不完整标记,不把漏读当作完整统计。浏览器取消不等于取消宿主端已经开始的统计计算。
92
94
  - **缓存命中率** = 缓存命中 token ÷ 输入 token 总量(命中 + 未命中)。
93
95
  - **失败数量**:以宿主记录的 `assistant/attempt` 事件为准。宿主只有在流以错误结束、或调用在提交任何内容之前被取消时才写 `assistant/attempt`,其余调用一律写 `assistant/message`,两者恰好划分全部调用,所以该事件就是「一次失败的 provider 调用」的精确边界(`interrupted: true` 但已有内容的 `assistant/message` 不计为失败)。失败数是请求数的子集,不额外计入请求;失败调用若在流中断前已上报用量,token 仍按用量计入、不会丢失。该计数晚于日 / 小时两份游标引入,因此老版本升级后会额外做一次只写失败数的增量回扫(`failuresReady` 标记完成,不重复累加 token)。
94
96
 
package/lib/client.js CHANGED
@@ -29423,8 +29423,8 @@ window.__ModuleLoader__.load({
29423
29423
  * every subagent session delegated from it. The native `tokenUsage` projection
29424
29424
  * covers only the main agent, so this is what makes a delegating session's
29425
29425
  * cost visible. */
29426
- async function fetchSessionUsage(sessionId) {
29427
- return post("session", { sessionId });
29426
+ async function fetchSessionUsage(sessionId, signal) {
29427
+ return post("session", { sessionId }, signal);
29428
29428
  }
29429
29429
  /** Log-owned requests and live route states for the per-session Usage view. */
29430
29430
  async function fetchSessionDetail(sessionId, signal) {
@@ -31550,8 +31550,9 @@ window.__ModuleLoader__.load({
31550
31550
  //#region src/client/use-session-usage.ts
31551
31551
  /** The composer row's window onto the whole session tree. The native usage
31552
31552
  * projection only moves for main-agent calls, so poll the displayed session
31553
- * even before its first child appears. Requests are serialized and late answers
31554
- * from a previous session are discarded. Failures keep the last good reading. */
31553
+ * even before its first child appears. Hidden pages pause; slow responses do
31554
+ * not queue timer retries. Disposed requests are aborted and late answers are
31555
+ * discarded. Failures keep the last good reading. */
31555
31556
  const SESSION_POLL_MS = 4e3;
31556
31557
  function useSessionUsage(sessionId, signature) {
31557
31558
  const [reading, setReading] = (0, react.useState)(null);
@@ -31559,34 +31560,61 @@ window.__ModuleLoader__.load({
31559
31560
  (0, react.useEffect)(() => {
31560
31561
  if (typeof sessionId !== "string" || sessionId === "") return;
31561
31562
  let active = true;
31562
- let pending = false;
31563
31563
  let again = false;
31564
+ let lastResult;
31565
+ let timer;
31566
+ let controller;
31567
+ const visible = () => typeof document === "undefined" || document.visibilityState !== "hidden";
31568
+ const clearTimer = () => {
31569
+ if (timer !== void 0) clearTimeout(timer);
31570
+ timer = void 0;
31571
+ };
31564
31572
  const request = () => {
31565
- if (!active) return;
31566
- if (pending) {
31573
+ if (!active || !visible()) return;
31574
+ if (controller !== void 0) {
31567
31575
  again = true;
31568
31576
  return;
31569
31577
  }
31570
- pending = true;
31571
- fetchSessionUsage(sessionId).then((tree) => {
31572
- if (active) setReading({
31573
- sessionId,
31574
- tree
31575
- });
31578
+ clearTimer();
31579
+ const current = new AbortController();
31580
+ controller = current;
31581
+ fetchSessionUsage(sessionId, current.signal).then((tree) => {
31582
+ if (!active || controller !== current || current.signal.aborted) return;
31583
+ const result = JSON.stringify(tree);
31584
+ if (result !== lastResult) {
31585
+ lastResult = result;
31586
+ setReading({
31587
+ sessionId,
31588
+ tree
31589
+ });
31590
+ }
31576
31591
  }).catch(() => {}).finally(() => {
31577
- pending = false;
31578
- if (active && again) {
31592
+ if (controller !== current) return;
31593
+ controller = void 0;
31594
+ if (!active || !visible()) return;
31595
+ if (again) {
31579
31596
  again = false;
31580
31597
  request();
31581
- }
31598
+ } else timer = setTimeout(request, SESSION_POLL_MS);
31582
31599
  });
31583
31600
  };
31601
+ const onVisibility = () => {
31602
+ clearTimer();
31603
+ if (!visible()) {
31604
+ again = false;
31605
+ controller?.abort();
31606
+ controller = void 0;
31607
+ } else request();
31608
+ };
31584
31609
  refresh.current = request;
31585
- const handle = setInterval(request, SESSION_POLL_MS);
31610
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
31586
31611
  return () => {
31587
31612
  active = false;
31588
31613
  refresh.current = null;
31589
- clearInterval(handle);
31614
+ clearTimer();
31615
+ controller?.abort();
31616
+ controller = void 0;
31617
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
31590
31618
  };
31591
31619
  }, [sessionId]);
31592
31620
  (0, react.useEffect)(() => {
package/lib/index.js CHANGED
@@ -1593,7 +1593,7 @@ async function readSession(persistence, id, header, live, signal) {
1593
1593
  }
1594
1594
  throw new Error("no readable persistence seam (need open or inspect)");
1595
1595
  }
1596
- async function readUsageTree(persistence, sessions, rootId, signal) {
1596
+ async function readUsageTree(persistence, sessions, rootId, signal, onCatalog) {
1597
1597
  signal?.throwIfAborted();
1598
1598
  const rows = await persistence.list(typeof persistence.open === "function" ? signal === void 0 ? void 0 : { signal } : signal);
1599
1599
  const headers = /* @__PURE__ */ new Map();
@@ -1603,11 +1603,14 @@ async function readUsageTree(persistence, sessions, rootId, signal) {
1603
1603
  }
1604
1604
  const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]));
1605
1605
  const children = /* @__PURE__ */ new Map();
1606
+ const parents = /* @__PURE__ */ new Map();
1607
+ for (const id of [...headers.keys(), ...live.keys()]) parents.set(id, /* @__PURE__ */ new Set());
1606
1608
  const link = (id, parent) => {
1607
1609
  if (typeof parent !== "string" || parent === "" || parent === id) return;
1608
1610
  const bucket = children.get(parent) ?? /* @__PURE__ */ new Set();
1609
1611
  bucket.add(id);
1610
1612
  children.set(parent, bucket);
1613
+ parents.get(id)?.add(parent);
1611
1614
  };
1612
1615
  for (const header of headers.values()) link(header.id, header.parentSession);
1613
1616
  for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession);
@@ -1620,6 +1623,10 @@ async function readUsageTree(persistence, sessions, rootId, signal) {
1620
1623
  ids.push(id);
1621
1624
  queue.push(id);
1622
1625
  }
1626
+ onCatalog?.({
1627
+ parents,
1628
+ members: seen
1629
+ });
1623
1630
  const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal);
1624
1631
  const logs = new Array(ids.length);
1625
1632
  const issues = new Array(ids.length);
@@ -2217,8 +2224,11 @@ function nameOf(h, id) {
2217
2224
  if (typeof x?.cwd === "string" && x.cwd) return x.cwd.split(/[\\/]/).filter(Boolean).pop() || id;
2218
2225
  return id;
2219
2226
  }
2220
- function one(log) {
2221
- const { id, header, live, events, inheritedCut: cut } = log;
2227
+ function ownEventsOf(log) {
2228
+ return log.events.filter((e) => !(log.inheritedCut > 0 && typeof e.seq === "number" && e.seq < log.inheritedCut));
2229
+ }
2230
+ function one(log, parentCatalogLabel) {
2231
+ const { id, header, live } = log;
2222
2232
  const fold = new UsageFold();
2223
2233
  const totals = zero$1();
2224
2234
  const records = [];
@@ -2229,15 +2239,14 @@ function one(log) {
2229
2239
  let effort = "";
2230
2240
  let pendingTopic = "";
2231
2241
  const turnTopics = /* @__PURE__ */ new Map();
2232
- const own = events.filter((e) => !(cut > 0 && typeof e.seq === "number" && e.seq < cut));
2242
+ const own = ownEventsOf(log);
2233
2243
  let label = "", title = "";
2234
2244
  for (const ev of own) {
2235
2245
  const d = ev.data ?? {};
2236
2246
  if (!label && ev.type === "subagent/descriptor" && typeof d.label === "string" && d.label.trim()) label = d.label.trim();
2237
- if (!title && ev.type === "session/title" && typeof d.title === "string" && d.title.trim()) title = d.title.trim();
2238
- if (label && title) break;
2247
+ if (ev.type === "session/title" && typeof d.title === "string" && d.title.trim()) title = d.title.trim();
2239
2248
  }
2240
- const name = label || title || nameOf(header, id);
2249
+ const name = label || title || parentCatalogLabel || nameOf(header, id);
2241
2250
  const stepTokens = /* @__PURE__ */ new Map();
2242
2251
  const closedSteps = /* @__PURE__ */ new Set();
2243
2252
  for (const ev of own) {
@@ -2418,7 +2427,21 @@ function one(log) {
2418
2427
  }
2419
2428
  async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
2420
2429
  const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId);
2421
- const parts = [root, ...descendants].map(one);
2430
+ const logs = [root, ...descendants];
2431
+ const catalogLabels = /* @__PURE__ */ new Map();
2432
+ for (const log of logs) {
2433
+ const labels = /* @__PURE__ */ new Map();
2434
+ for (const event of ownEventsOf(log)) {
2435
+ if (event.type !== "subagent/catalog") continue;
2436
+ const data = event.data;
2437
+ if (typeof data?.childId === "string" && data.childId && typeof data.label === "string" && data.label.trim()) labels.set(data.childId, data.label.trim());
2438
+ }
2439
+ catalogLabels.set(log.id, labels);
2440
+ }
2441
+ const parts = logs.map((log) => {
2442
+ const parentId = log.header?.parentSession ?? log.live?.header?.parentSession ?? log.live?.parentSession;
2443
+ return one(log, parentId === void 0 ? void 0 : catalogLabels.get(parentId)?.get(log.id));
2444
+ });
2422
2445
  const totals = zero$1();
2423
2446
  const models = /* @__PURE__ */ new Map();
2424
2447
  const records = parts.flatMap((x) => x.records);
@@ -2921,25 +2944,99 @@ function sumOneSession(log) {
2921
2944
  compaction
2922
2945
  };
2923
2946
  }
2924
- const CACHE_TTL_MS = 4e3;
2947
+ const CACHE_TTL_MS = 6e4;
2925
2948
  const CACHE_MAX = 64;
2926
2949
  var SessionUsageService = class {
2950
+ cacheTtlMs;
2927
2951
  cache = /* @__PURE__ */ new Map();
2928
- generation = 0;
2929
- /** New events can introduce descendants of any cached root. */
2930
- invalidate(_sessionId) {
2931
- this.generation++;
2932
- this.cache.clear();
2933
- }
2934
- /** A bad descendant leaves the readable totals available with coverage.
2935
- * Never cache incomplete results: a repaired log must recover on the next read.
2936
- * Root, catalog and cancellation failures still reject without publishing zero. */
2952
+ parents = /* @__PURE__ */ new Map();
2953
+ catalogVersion = 0;
2954
+ catalogOrder = 0;
2955
+ appliedCatalogOrder = 0;
2956
+ reads = /* @__PURE__ */ new Set();
2957
+ inFlight = /* @__PURE__ */ new Map();
2958
+ /** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
2959
+ * Hosts without a live event hook can select the old four-second fallback. */
2960
+ constructor(cacheTtlMs = CACHE_TTL_MS) {
2961
+ this.cacheTtlMs = cacheTtlMs;
2962
+ }
2963
+ /** Invalidate the changed session and every old/new ancestor, not other trees.
2964
+ * null is a known root; undefined means parent metadata was not supplied.
2965
+ * An unknown parent chain conservatively falls back instead of missing a new
2966
+ * child that appeared before the persistence catalog caught up. */
2967
+ invalidate(sessionId, parentSession) {
2968
+ this.catalogVersion++;
2969
+ const previous = this.parents.get(sessionId);
2970
+ const queue = [...previous ?? []];
2971
+ let uncertain = previous === void 0 && parentSession === void 0;
2972
+ if (parentSession !== void 0) {
2973
+ const current = /* @__PURE__ */ new Set();
2974
+ if (parentSession !== null && parentSession !== "" && parentSession !== sessionId) current.add(parentSession);
2975
+ if (previous !== void 0) this.parents.set(sessionId, current);
2976
+ queue.push(...current);
2977
+ }
2978
+ const affected = /* @__PURE__ */ new Set([sessionId]);
2979
+ for (let index = 0; index < queue.length; index++) {
2980
+ const id = queue[index];
2981
+ if (affected.has(id)) continue;
2982
+ affected.add(id);
2983
+ const ancestors = this.parents.get(id);
2984
+ if (ancestors === void 0) uncertain = true;
2985
+ else queue.push(...ancestors);
2986
+ }
2987
+ const touches = (read) => {
2988
+ if (affected.has(read.rootId)) return true;
2989
+ for (const id of affected) if (read.members.has(id)) return true;
2990
+ return false;
2991
+ };
2992
+ for (const [rootId, entry] of this.cache) if (uncertain || touches(entry.read)) this.cache.delete(rootId);
2993
+ for (const read of this.reads) if (!read.catalogReady || uncertain || touches(read)) read.invalidated = true;
2994
+ }
2995
+ /** A bad descendant leaves readable totals available, but is never cached.
2996
+ * Signal-less browser requests share one cold read per root. Explicit signals
2997
+ * retain independent ownership and are passed through unchanged: aborting one
2998
+ * caller cannot cancel another caller's readers or publish its partial totals. */
2937
2999
  async tree(persistence, sessions, rootId, signal) {
2938
3000
  signal?.throwIfAborted();
2939
3001
  const hit = this.cache.get(rootId);
2940
- if (hit !== void 0 && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
2941
- const generation = this.generation;
2942
- const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId, signal);
3002
+ if (hit !== void 0 && hit.read.persistence === persistence && hit.read.sessions === sessions && Date.now() - hit.at < this.cacheTtlMs) {
3003
+ this.cache.delete(rootId);
3004
+ this.cache.set(rootId, hit);
3005
+ return hit.value;
3006
+ }
3007
+ this.cache.delete(rootId);
3008
+ const pending = this.inFlight.get(rootId);
3009
+ if (signal === void 0 && pending !== void 0 && !pending.read.invalidated && pending.read.persistence === persistence && pending.read.sessions === sessions) return pending.promise;
3010
+ const read = {
3011
+ rootId,
3012
+ persistence,
3013
+ sessions,
3014
+ members: /* @__PURE__ */ new Set([rootId]),
3015
+ invalidated: false,
3016
+ catalogReady: false,
3017
+ catalogVersion: this.catalogVersion,
3018
+ catalogOrder: ++this.catalogOrder
3019
+ };
3020
+ this.reads.add(read);
3021
+ const promise = this.compute(read, signal).finally(() => {
3022
+ this.reads.delete(read);
3023
+ if (this.inFlight.get(rootId)?.read === read) this.inFlight.delete(rootId);
3024
+ });
3025
+ if (signal === void 0) this.inFlight.set(rootId, {
3026
+ read,
3027
+ promise
3028
+ });
3029
+ return promise;
3030
+ }
3031
+ async compute(read, signal) {
3032
+ const { root, descendants, coverage } = await readUsageTree(read.persistence, read.sessions, read.rootId, signal, (catalog) => {
3033
+ read.members = catalog.members;
3034
+ read.catalogReady = true;
3035
+ if (read.catalogVersion === this.catalogVersion && read.catalogOrder > this.appliedCatalogOrder) {
3036
+ this.parents = new Map(catalog.parents);
3037
+ this.appliedCatalogOrder = read.catalogOrder;
3038
+ }
3039
+ });
2943
3040
  const own = sumOneSession(root);
2944
3041
  const subagents = zero();
2945
3042
  for (const log of descendants) add(subagents, sumOneSession(log).totals);
@@ -2954,11 +3051,18 @@ var SessionUsageService = class {
2954
3051
  coverage
2955
3052
  };
2956
3053
  signal?.throwIfAborted();
2957
- if (coverage.complete && generation === this.generation) {
2958
- if (this.cache.size >= CACHE_MAX) this.cache.clear();
2959
- this.cache.set(rootId, {
3054
+ const newer = this.cache.get(read.rootId);
3055
+ if (coverage.complete && !read.invalidated && (newer === void 0 || newer.read.catalogOrder < read.catalogOrder)) {
3056
+ for (const older of this.reads) if (older.rootId === read.rootId && older.persistence === read.persistence && older.sessions === read.sessions && older.catalogOrder < read.catalogOrder) older.invalidated = true;
3057
+ this.cache.delete(read.rootId);
3058
+ if (this.cache.size >= CACHE_MAX) {
3059
+ const oldest = this.cache.keys().next().value;
3060
+ if (oldest !== void 0) this.cache.delete(oldest);
3061
+ }
3062
+ this.cache.set(read.rootId, {
2960
3063
  at: Date.now(),
2961
- value
3064
+ value,
3065
+ read
2962
3066
  });
2963
3067
  }
2964
3068
  return value;
@@ -3010,7 +3114,8 @@ function apply(ctx, config = {}) {
3010
3114
  source: config.source,
3011
3115
  backfillConcurrency: config.backfillConcurrency
3012
3116
  });
3013
- const sessionUsage = new SessionUsageService();
3117
+ const sessionEvents = ctx;
3118
+ const sessionUsage = new SessionUsageService(typeof sessionEvents.on === "function" ? void 0 : 4e3);
3014
3119
  const trust = () => {
3015
3120
  const runtime = ctx.get("webRuntime");
3016
3121
  const startup = ctx.get("webStartup");
@@ -3112,9 +3217,13 @@ function apply(ctx, config = {}) {
3112
3217
  collector.start();
3113
3218
  const onEvent = (session) => {
3114
3219
  const id = typeof session?.id === "string" ? session.id : "";
3115
- if (id !== "") sessionUsage.invalidate(id);
3220
+ if (id === "") return;
3221
+ const owner = ctx.sessions.get?.(id) ?? session;
3222
+ const parent = owner.header?.parentSession ?? owner.parentSession;
3223
+ const knownParent = owner.header !== void 0 || "parentSession" in owner;
3224
+ sessionUsage.invalidate(id, knownParent ? parent ?? null : void 0);
3116
3225
  };
3117
- ctx.on?.("session/event", onEvent);
3226
+ sessionEvents.on?.("session/event", onEvent);
3118
3227
  (async () => {
3119
3228
  await rescan();
3120
3229
  await ensureHourHistory();
@@ -28,6 +28,6 @@ export declare function rebuildStats(): Promise<BackfillStatus>;
28
28
  * every subagent session delegated from it. The native `tokenUsage` projection
29
29
  * covers only the main agent, so this is what makes a delegating session's
30
30
  * cost visible. */
31
- export declare function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree>;
31
+ export declare function fetchSessionUsage(sessionId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
32
32
  /** Log-owned requests and live route states for the per-session Usage view. */
33
33
  export declare function fetchSessionDetail(sessionId: string, signal?: AbortSignal): Promise<SessionUsageDetail>;
@@ -9,7 +9,12 @@ export interface UsageSessionLog {
9
9
  events: readonly UsageSessionEvent[];
10
10
  inheritedCut: number;
11
11
  }
12
- export declare function readUsageTree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<{
12
+ /** Metadata only: includes unreadable descendants and both persisted/live links. */
13
+ export interface UsageTreeCatalog {
14
+ parents: ReadonlyMap<string, ReadonlySet<string>>;
15
+ members: ReadonlySet<string>;
16
+ }
17
+ export declare function readUsageTree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal, onCatalog?: (catalog: UsageTreeCatalog) => void): Promise<{
13
18
  root: UsageSessionLog;
14
19
  descendants: UsageSessionLog[];
15
20
  coverage: SessionUsageCoverage;
@@ -3,12 +3,26 @@ import type { UsageSessionPersistence, UsageSessionStore } from './context-types
3
3
  import type { SessionUsageTree } from './wire.ts';
4
4
  export type { SessionUsageTotals, SessionUsageTree } from './wire.ts';
5
5
  export declare class SessionUsageService {
6
+ private readonly cacheTtlMs;
6
7
  private cache;
7
- private generation;
8
- /** New events can introduce descendants of any cached root. */
9
- invalidate(_sessionId: string): void;
10
- /** A bad descendant leaves the readable totals available with coverage.
11
- * Never cache incomplete results: a repaired log must recover on the next read.
12
- * Root, catalog and cancellation failures still reject without publishing zero. */
8
+ private parents;
9
+ private catalogVersion;
10
+ private catalogOrder;
11
+ private appliedCatalogOrder;
12
+ private reads;
13
+ private inFlight;
14
+ /** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
15
+ * Hosts without a live event hook can select the old four-second fallback. */
16
+ constructor(cacheTtlMs?: number);
17
+ /** Invalidate the changed session and every old/new ancestor, not other trees.
18
+ * null is a known root; undefined means parent metadata was not supplied.
19
+ * An unknown parent chain conservatively falls back instead of missing a new
20
+ * child that appeared before the persistence catalog caught up. */
21
+ invalidate(sessionId: string, parentSession?: string | null): void;
22
+ /** A bad descendant leaves readable totals available, but is never cached.
23
+ * Signal-less browser requests share one cold read per root. Explicit signals
24
+ * retain independent ownership and are passed through unchanged: aborting one
25
+ * caller cannot cancel another caller's readers or publish its partial totals. */
13
26
  tree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
27
+ private compute;
14
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
package/src/client/api.ts CHANGED
@@ -71,8 +71,8 @@ export async function rebuildStats(): Promise<BackfillStatus> {
71
71
  * every subagent session delegated from it. The native `tokenUsage` projection
72
72
  * covers only the main agent, so this is what makes a delegating session's
73
73
  * cost visible. */
74
- export async function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree> {
75
- return post<SessionUsageTree>('session', { sessionId })
74
+ export async function fetchSessionUsage(sessionId: string, signal?: AbortSignal): Promise<SessionUsageTree> {
75
+ return post<SessionUsageTree>('session', { sessionId }, signal)
76
76
  }
77
77
 
78
78
  /** Log-owned requests and live route states for the per-session Usage view. */
@@ -1,7 +1,8 @@
1
1
  /** The composer row's window onto the whole session tree. The native usage
2
2
  * projection only moves for main-agent calls, so poll the displayed session
3
- * even before its first child appears. Requests are serialized and late answers
4
- * from a previous session are discarded. Failures keep the last good reading. */
3
+ * even before its first child appears. Hidden pages pause; slow responses do
4
+ * not queue timer retries. Disposed requests are aborted and late answers are
5
+ * discarded. Failures keep the last good reading. */
5
6
  import { useEffect, useRef, useState } from 'react'
6
7
  import { fetchSessionUsage } from './api.ts'
7
8
  import { combinedTotals } from './session-usage-core.ts'
@@ -23,33 +24,64 @@ export function useSessionUsage(sessionId: string | undefined, signature: string
23
24
  useEffect(() => {
24
25
  if (typeof sessionId !== 'string' || sessionId === '') return
25
26
  let active = true
26
- let pending = false
27
27
  let again = false
28
+ let lastResult: string | undefined
29
+ let timer: ReturnType<typeof setTimeout> | undefined
30
+ let controller: AbortController | undefined
31
+ const visible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
32
+ const clearTimer = (): void => {
33
+ if (timer !== undefined) clearTimeout(timer)
34
+ timer = undefined
35
+ }
28
36
  const request = (): void => {
29
- if (!active) return
30
- if (pending) {
37
+ if (!active || !visible()) return
38
+ if (controller !== undefined) {
31
39
  again = true
32
40
  return
33
41
  }
34
- pending = true
35
- void fetchSessionUsage(sessionId).then((tree) => {
36
- if (active) setReading({ sessionId, tree })
42
+ clearTimer()
43
+ const current = new AbortController()
44
+ controller = current
45
+ void fetchSessionUsage(sessionId, current.signal).then((tree) => {
46
+ if (!active || controller !== current || current.signal.aborted) return
47
+ const result = JSON.stringify(tree)
48
+ if (result !== lastResult) {
49
+ lastResult = result
50
+ setReading({ sessionId, tree })
51
+ }
37
52
  }).catch(() => {
38
53
  // Retry on the next beat/signature; do not replace good data with zero.
39
54
  }).finally(() => {
40
- pending = false
41
- if (active && again) {
55
+ if (controller !== current) return
56
+ controller = undefined
57
+ if (!active || !visible()) return
58
+ if (again) {
42
59
  again = false
43
60
  request()
61
+ } else {
62
+ timer = setTimeout(request, SESSION_POLL_MS)
44
63
  }
45
64
  })
46
65
  }
66
+ const onVisibility = (): void => {
67
+ clearTimer()
68
+ if (!visible()) {
69
+ again = false
70
+ controller?.abort()
71
+ controller = undefined
72
+ } else {
73
+ request()
74
+ }
75
+ }
47
76
  refresh.current = request
48
- const handle = setInterval(request, SESSION_POLL_MS)
77
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
49
78
  return () => {
50
79
  active = false
51
80
  refresh.current = null
52
- clearInterval(handle)
81
+ clearTimer()
82
+ controller?.abort()
83
+ controller = undefined
84
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
53
85
  }
54
86
  }, [sessionId])
55
87
  useEffect(() => {
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * the browser half over the fenced route.
13
13
  */
14
14
 
15
- import type { Context, UsageWebRuntime, UsageWebStartup } from './context-types.ts'
15
+ import type { Context, UsageSessionHandle, UsageWebRuntime, UsageWebStartup } from './context-types.ts'
16
16
  import type { TrustInputs } from './trust-fence.ts'
17
17
  import { UsageStore } from './store.ts'
18
18
  import { UsageCollector } from './collector.ts'
@@ -72,10 +72,11 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
72
72
  source: config.source,
73
73
  backfillConcurrency: config.backfillConcurrency,
74
74
  })
75
- // Per-session (main + subagent) usage for the composer stats row. Its cache
76
- // is invalidated on every live event of the touched session (see the
77
- // collector subscription below), so the pill never shows a stale tree.
78
- const sessionUsage = new SessionUsageService()
75
+ // Committed events invalidate only the touched tree and its ancestors. With
76
+ // that hook, unchanged histories can stay cached longer; older hosts without
77
+ // it retain four-second catalog revalidation rather than stale live totals.
78
+ const sessionEvents = ctx as unknown as { on?: (name: string, fn: (s: Partial<UsageSessionHandle>) => void) => void }
79
+ const sessionUsage = new SessionUsageService(typeof sessionEvents.on === 'function' ? undefined : 4_000)
79
80
 
80
81
  // The /token-total/api trust inputs, resolved live so the fence reads the same sources
81
82
  // the /api gateway derives them from. DSH 0.2.1-alpha.2 moved the explicit
@@ -242,15 +243,17 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
242
243
 
243
244
  ctx.effect(() => {
244
245
  collector.start()
245
- // Keep the per-session tree cache honest: every committed event of any
246
- // session (main or subagent) changes at least one cached tree, so drop the
247
- // ones that counted it. Observational only — a missing hook is harmless.
248
- const onEvent = (session: { id?: unknown }): void => {
246
+ // Include live ancestry: a new child may not be in the persisted catalog
247
+ // yet. Missing metadata takes the service's conservative fallback path.
248
+ const onEvent = (session: Partial<UsageSessionHandle>): void => {
249
249
  const id = typeof session?.id === 'string' ? session.id : ''
250
- if (id !== '') sessionUsage.invalidate(id)
250
+ if (id === '') return
251
+ const owner = ctx.sessions.get?.(id) ?? session
252
+ const parent = owner.header?.parentSession ?? owner.parentSession
253
+ const knownParent = owner.header !== undefined || 'parentSession' in owner
254
+ sessionUsage.invalidate(id, knownParent ? parent ?? null : undefined)
251
255
  }
252
- ;(ctx as unknown as { on?: (name: string, fn: (s: { id?: unknown }) => void) => void })
253
- .on?.('session/event', onEvent)
256
+ sessionEvents.on?.('session/event', onEvent)
254
257
  void (async () => {
255
258
  await rescan()
256
259
  await ensureHourHistory()
@@ -86,8 +86,12 @@ function nameOf(h: UsageSessionHeader | undefined, id: string): string {
86
86
  return id
87
87
  }
88
88
 
89
- function one(log: UsageSessionLog) {
90
- const { id, header, live, events, inheritedCut: cut } = log
89
+ function ownEventsOf(log: UsageSessionLog) {
90
+ return log.events.filter(e => !(log.inheritedCut > 0 && typeof e.seq === 'number' && e.seq < log.inheritedCut))
91
+ }
92
+
93
+ function one(log: UsageSessionLog, parentCatalogLabel?: string) {
94
+ const { id, header, live } = log
91
95
  const fold = new UsageFold(); const totals = zero(); const records: MutableRecord[] = []
92
96
  const byKey = new Map<string, MutableRecord>(); let route = live?.requestContext?.() ?? {}
93
97
  let ordinal = 0
@@ -96,18 +100,16 @@ function one(log: UsageSessionLog) {
96
100
  // the prompt usually lands before `turn/start`, so buffer it until the turn opens.
97
101
  let currentTurn = 0; let effort = ''; let pendingTopic = ''
98
102
  const turnTopics = new Map<number, string>()
99
- const own = events.filter(e => !(cut > 0 && typeof e.seq === 'number' && e.seq < cut))
100
- // Real session names: a subagent carries its delegation label on its
101
- // `subagent/descriptor` event ("完成日志明细后端"), every session can carry a
102
- // generated `session/title`; only fall back to the header/cwd/id guesses.
103
+ const own = ownEventsOf(log)
104
+ // Prefer the child's own delegation label or latest title. Older CLI records
105
+ // carry their task name only in the actual parent's subagent/catalog.
103
106
  let label = '', title = ''
104
107
  for (const ev of own) {
105
108
  const d: any = ev.data ?? {}
106
109
  if (!label && ev.type === 'subagent/descriptor' && typeof d.label === 'string' && d.label.trim()) label = d.label.trim()
107
- if (!title && ev.type === 'session/title' && typeof d.title === 'string' && d.title.trim()) title = d.title.trim()
108
- if (label && title) break
110
+ if (ev.type === 'session/title' && typeof d.title === 'string' && d.title.trim()) title = d.title.trim()
109
111
  }
110
- const name = label || title || nameOf(header, id)
112
+ const name = label || title || parentCatalogLabel || nameOf(header, id)
111
113
  // Decode accounting mirrors the official `sessionStats` projection: a step
112
114
  // records its first token once (the earliest attempt wins, surviving an
113
115
  // in-step retry), and only the assembled `assistant/message` closes the
@@ -192,7 +194,23 @@ function one(log: UsageSessionLog) {
192
194
 
193
195
  export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
194
196
  const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId)
195
- const parts = [root, ...descendants].map(one)
197
+ const logs = [root, ...descendants]
198
+ const catalogLabels = new Map<string, Map<string, string>>()
199
+ for (const log of logs) {
200
+ const labels = new Map<string, string>()
201
+ for (const event of ownEventsOf(log)) {
202
+ if (event.type !== 'subagent/catalog') continue
203
+ const data = event.data as { childId?: unknown; label?: unknown } | null
204
+ if (typeof data?.childId === 'string' && data.childId && typeof data.label === 'string' && data.label.trim()) {
205
+ labels.set(data.childId, data.label.trim())
206
+ }
207
+ }
208
+ catalogLabels.set(log.id, labels)
209
+ }
210
+ const parts = logs.map(log => {
211
+ const parentId = log.header?.parentSession ?? log.live?.header?.parentSession ?? log.live?.parentSession
212
+ return one(log, parentId === undefined ? undefined : catalogLabels.get(parentId)?.get(log.id))
213
+ })
196
214
  const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
197
215
  for (const r of records) { const key = r.model || '(unknown)'; const m = models.get(key) ?? { totals: zero(), requests: 0, providers: new Set<string>() }; add(m.totals, r.totals); m.requests++; if (r.provider) m.providers.add(r.provider); models.set(key, m) }
198
216
  const subagents = parts.slice(1).map(p => ({ sessionId: p.id, name: p.name, totals: p.totals, requests: p.requests, averageFirstTokenMs: p.averageFirstTokenMs, tokensPerSecond: p.tokensPerSecond }))
@@ -18,6 +18,12 @@ export interface UsageSessionLog {
18
18
  inheritedCut: number
19
19
  }
20
20
 
21
+ /** Metadata only: includes unreadable descendants and both persisted/live links. */
22
+ export interface UsageTreeCatalog {
23
+ parents: ReadonlyMap<string, ReadonlySet<string>>
24
+ members: ReadonlySet<string>
25
+ }
26
+
21
27
  function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
22
28
  if (!item || typeof item !== 'object') return null
23
29
  const header = 'header' in item ? item.header : item
@@ -84,6 +90,7 @@ export async function readUsageTree(
84
90
  sessions: UsageSessionStore | undefined,
85
91
  rootId: string,
86
92
  signal?: AbortSignal,
93
+ onCatalog?: (catalog: UsageTreeCatalog) => void,
87
94
  ): Promise<{ root: UsageSessionLog; descendants: UsageSessionLog[]; coverage: SessionUsageCoverage }> {
88
95
  signal?.throwIfAborted()
89
96
  const rows = await persistence.list(typeof persistence.open === 'function'
@@ -96,11 +103,14 @@ export async function readUsageTree(
96
103
  }
97
104
  const live = new Map((sessions?.list() ?? []).map(handle => [handle.id, handle]))
98
105
  const children = new Map<string, Set<string>>()
106
+ const parents = new Map<string, Set<string>>()
107
+ for (const id of [...headers.keys(), ...live.keys()]) parents.set(id, new Set())
99
108
  const link = (id: string, parent: unknown): void => {
100
109
  if (typeof parent !== 'string' || parent === '' || parent === id) return
101
110
  const bucket = children.get(parent) ?? new Set<string>()
102
111
  bucket.add(id)
103
112
  children.set(parent, bucket)
113
+ parents.get(id)?.add(parent)
104
114
  }
105
115
  for (const header of headers.values()) link(header.id, header.parentSession)
106
116
  for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession)
@@ -115,6 +125,9 @@ export async function readUsageTree(
115
125
  queue.push(id)
116
126
  }
117
127
  }
128
+ // Publish membership before any asynchronous log read, so events arriving
129
+ // during that read can invalidate the right root, including bad descendants.
130
+ onCatalog?.({ parents, members: seen })
118
131
  const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal)
119
132
  const logs: Array<UsageSessionLog | undefined> = new Array(ids.length)
120
133
  const issues: Array<SessionUsageReadIssue | undefined> = new Array(ids.length)
@@ -34,22 +34,85 @@ function sumOneSession(log: UsageSessionLog): { totals: SessionUsageTotals; comp
34
34
  return { totals, compaction }
35
35
  }
36
36
 
37
- const CACHE_TTL_MS = 4_000
37
+ const CACHE_TTL_MS = 60_000
38
38
  const CACHE_MAX = 64
39
39
 
40
+ interface TreeRead {
41
+ rootId: string
42
+ persistence: UsageSessionPersistence
43
+ sessions: UsageSessionStore | undefined
44
+ members: ReadonlySet<string>
45
+ invalidated: boolean
46
+ catalogReady: boolean
47
+ catalogVersion: number
48
+ catalogOrder: number
49
+ }
50
+ interface CachedTree {
51
+ at: number
52
+ value: SessionUsageTree
53
+ read: TreeRead
54
+ }
55
+
40
56
  export class SessionUsageService {
41
- private cache = new Map<string, { at: number; value: SessionUsageTree }>()
42
- private generation = 0
57
+ private cache = new Map<string, CachedTree>()
58
+ private parents = new Map<string, ReadonlySet<string>>()
59
+ private catalogVersion = 0
60
+ private catalogOrder = 0
61
+ private appliedCatalogOrder = 0
62
+ private reads = new Set<TreeRead>()
63
+ private inFlight = new Map<string, { read: TreeRead; promise: Promise<SessionUsageTree> }>()
64
+
65
+ /** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
66
+ * Hosts without a live event hook can select the old four-second fallback. */
67
+ constructor(private readonly cacheTtlMs = CACHE_TTL_MS) {}
43
68
 
44
- /** New events can introduce descendants of any cached root. */
45
- invalidate(_sessionId: string): void {
46
- this.generation++
47
- this.cache.clear()
69
+ /** Invalidate the changed session and every old/new ancestor, not other trees.
70
+ * null is a known root; undefined means parent metadata was not supplied.
71
+ * An unknown parent chain conservatively falls back instead of missing a new
72
+ * child that appeared before the persistence catalog caught up. */
73
+ invalidate(sessionId: string, parentSession?: string | null): void {
74
+ this.catalogVersion++
75
+ const previous = this.parents.get(sessionId)
76
+ const queue = [...(previous ?? [])]
77
+ let uncertain = previous === undefined && parentSession === undefined
78
+ if (parentSession !== undefined) {
79
+ const current = new Set<string>()
80
+ if (parentSession !== null && parentSession !== '' && parentSession !== sessionId) current.add(parentSession)
81
+ // Do not accumulate unbounded event-only ids between catalog reads.
82
+ // Unknown hints still invalidate their ancestors immediately.
83
+ if (previous !== undefined) this.parents.set(sessionId, current)
84
+ queue.push(...current)
85
+ }
86
+ const affected = new Set([sessionId])
87
+ for (let index = 0; index < queue.length; index++) {
88
+ const id = queue[index]!
89
+ if (affected.has(id)) continue
90
+ affected.add(id)
91
+ const ancestors = this.parents.get(id)
92
+ if (ancestors === undefined) uncertain = true
93
+ else queue.push(...ancestors)
94
+ }
95
+ const touches = (read: TreeRead): boolean => {
96
+ if (affected.has(read.rootId)) return true
97
+ // A newer catalog may have moved an ancestor away from this older tree.
98
+ // Changes below that ancestor must still invalidate its old membership.
99
+ for (const id of affected) if (read.members.has(id)) return true
100
+ return false
101
+ }
102
+ for (const [rootId, entry] of this.cache) {
103
+ if (uncertain || touches(entry.read)) this.cache.delete(rootId)
104
+ }
105
+ // Detached older reads remain tracked until they settle. A new request must
106
+ // not join an invalidated read, and that read must never repopulate the cache.
107
+ for (const read of this.reads) {
108
+ if (!read.catalogReady || uncertain || touches(read)) read.invalidated = true
109
+ }
48
110
  }
49
111
 
50
- /** A bad descendant leaves the readable totals available with coverage.
51
- * Never cache incomplete results: a repaired log must recover on the next read.
52
- * Root, catalog and cancellation failures still reject without publishing zero. */
112
+ /** A bad descendant leaves readable totals available, but is never cached.
113
+ * Signal-less browser requests share one cold read per root. Explicit signals
114
+ * retain independent ownership and are passed through unchanged: aborting one
115
+ * caller cannot cancel another caller's readers or publish its partial totals. */
53
116
  async tree(
54
117
  persistence: UsageSessionPersistence,
55
118
  sessions: UsageSessionStore | undefined,
@@ -58,9 +121,43 @@ export class SessionUsageService {
58
121
  ): Promise<SessionUsageTree> {
59
122
  signal?.throwIfAborted()
60
123
  const hit = this.cache.get(rootId)
61
- if (hit !== undefined && Date.now() - hit.at < CACHE_TTL_MS) return hit.value
62
- const generation = this.generation
63
- const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId, signal)
124
+ if (hit !== undefined && hit.read.persistence === persistence && hit.read.sessions === sessions
125
+ && Date.now() - hit.at < this.cacheTtlMs) {
126
+ // Map insertion order is the LRU; a hit must protect a popular history.
127
+ this.cache.delete(rootId)
128
+ this.cache.set(rootId, hit)
129
+ return hit.value
130
+ }
131
+ this.cache.delete(rootId)
132
+ const pending = this.inFlight.get(rootId)
133
+ if (signal === undefined && pending !== undefined && !pending.read.invalidated
134
+ && pending.read.persistence === persistence && pending.read.sessions === sessions) return pending.promise
135
+
136
+ const read: TreeRead = {
137
+ rootId, persistence, sessions, members: new Set([rootId]), invalidated: false,
138
+ catalogReady: false, catalogVersion: this.catalogVersion, catalogOrder: ++this.catalogOrder,
139
+ }
140
+ this.reads.add(read)
141
+ const promise = this.compute(read, signal).finally(() => {
142
+ this.reads.delete(read)
143
+ if (this.inFlight.get(rootId)?.read === read) this.inFlight.delete(rootId)
144
+ })
145
+ if (signal === undefined) this.inFlight.set(rootId, { read, promise })
146
+ return promise
147
+ }
148
+
149
+ private async compute(read: TreeRead, signal?: AbortSignal): Promise<SessionUsageTree> {
150
+ const { root, descendants, coverage } = await readUsageTree(
151
+ read.persistence, read.sessions, read.rootId, signal, (catalog) => {
152
+ read.members = catalog.members
153
+ read.catalogReady = true
154
+ // A slow older listing must not undo newer catalogs or event hints.
155
+ if (read.catalogVersion === this.catalogVersion && read.catalogOrder > this.appliedCatalogOrder) {
156
+ this.parents = new Map(catalog.parents)
157
+ this.appliedCatalogOrder = read.catalogOrder
158
+ }
159
+ },
160
+ )
64
161
  const own = sumOneSession(root)
65
162
  const subagents = zero()
66
163
  for (const log of descendants) add(subagents, sumOneSession(log).totals)
@@ -75,9 +172,20 @@ export class SessionUsageService {
75
172
  coverage,
76
173
  }
77
174
  signal?.throwIfAborted()
78
- if (coverage.complete && generation === this.generation) {
79
- if (this.cache.size >= CACHE_MAX) this.cache.clear()
80
- this.cache.set(rootId, { at: Date.now(), value })
175
+ const newer = this.cache.get(read.rootId)
176
+ if (coverage.complete && !read.invalidated && (newer === undefined || newer.read.catalogOrder < read.catalogOrder)) {
177
+ // Retire older same-source snapshots before publishing. LRU eviction of
178
+ // this result must not let an older detached read be joined or cached.
179
+ for (const older of this.reads) {
180
+ if (older.rootId === read.rootId && older.persistence === read.persistence
181
+ && older.sessions === read.sessions && older.catalogOrder < read.catalogOrder) older.invalidated = true
182
+ }
183
+ this.cache.delete(read.rootId)
184
+ if (this.cache.size >= CACHE_MAX) {
185
+ const oldest = this.cache.keys().next().value
186
+ if (oldest !== undefined) this.cache.delete(oldest)
187
+ }
188
+ this.cache.set(read.rootId, { at: Date.now(), value, read })
81
189
  }
82
190
  return value
83
191
  }