dsh-activity-pane 0.10.0 → 0.10.1

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.
@@ -2236,6 +2236,32 @@ function reconcileTurnStats(current, records, { closeOpenTurn = false, forceFres
2236
2236
  return { ...state, watermarkSeq, watermarkTime };
2237
2237
  }
2238
2238
 
2239
+ /**
2240
+ * 会话级登记串行化(R-01-020/AC-02):宿主存储域的 `KvTable.put` 先异步落盘、落盘完成
2241
+ * 后才更新内存快照,因此同一会话的两个边界事件 handler 并发执行时,后到事件会在先到
2242
+ * 事件落盘前读到旧状态,其效果被当作「无效果」永久丢弃(实测:`ask_user_question` 的
2243
+ * `tool/call` 与 11ms 后的 `tool/result` 交错,等待区间悬挂到 `turn/end` 强制结算,
2244
+ * 总耗时系统性缺记整段运行过程)。返回 `(id, run) => tail`:同一会话前一个 run 完成
2245
+ * (含其 put 的内存生效)后才执行下一个 run,不同会话互不阻塞。run 内部须自行捕获
2246
+ * 错误、不得让拒绝外泄(调用方可安全丢弃返回的 tail);tail 供调用方等待与测试断言。
2247
+ * 链空闲即清理,不随会话数增长。
2248
+ */
2249
+ function createSessionEventSerializer() {
2250
+ const tails = new Map();
2251
+ return (id, run) => {
2252
+ const prev = tails.get(id) ?? Promise.resolve();
2253
+ const tail = prev.then(run, run);
2254
+ tails.set(id, tail);
2255
+ tail.then(
2256
+ () => {
2257
+ if (tails.get(id) === tail) tails.delete(id);
2258
+ },
2259
+ () => {},
2260
+ );
2261
+ return tail;
2262
+ };
2263
+ }
2264
+
2239
2265
  /**
2240
2266
  * 构建历史区条目:当前非活动的**主会话**,按最后活动时间从新到旧返回完整候选集合。
2241
2267
  * 子代理是临时工作单元,不入历史区;归档、空会话、完成/错误提醒与委托周期中的会话
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-activity-pane",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "engines": {
6
6
  "node": ">=20"
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,
@@ -1231,6 +1234,39 @@ assert.equal(fmtElapsedMs(-1), "", "负时长归一为空");
1231
1234
  }
1232
1235
  assert.equal(stats.busyMs, 1_000, "R-01-020/AC-04 水位检查下重复推送不重复计数");
1233
1236
  }
1237
+ // R-01-020/AC-02:实时登记的会话级串行化——宿主 KvTable.put 先落盘后更新内存快照,
1238
+ // 并发到达的边界事件若各自读-改-写,后到事件会在先到事件落盘前读到旧状态,其效果被
1239
+ // 当作「无效果」永久丢弃(实测:提问 tool/call 与 11ms 后的 tool/result 结算丢失,
1240
+ // 等待区间悬挂到 turn/end 强制结算,总耗时缺记整段运行过程)。串行化后即便全部 handler
1241
+ // 同步注册、put 延迟落盘,最终记账也必须与顺序重放一致。
1242
+ {
1243
+ const events = [
1244
+ { type: "turn/start", seq: 1, time: 1_000 },
1245
+ { type: "tool/call", seq: 2, time: 2_000, data: { callId: "c1", name: "ask_user_question" } },
1246
+ { type: "tool/result", seq: 3, time: 2_011, data: { message: { source: { kind: "tool", callId: "c1" } } } },
1247
+ { type: "turn/end", seq: 4, time: 5_000, data: { reason: { kind: "completed" } } },
1248
+ ];
1249
+ let sequential = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null };
1250
+ for (const event of events) sequential = applyTurnEventToStats(sequential, event);
1251
+ assert.equal(sequential.busyMs, 4_000 - 11, "R-01-020/AC-02 顺序重放基线:回合 4s 扣提问等待 11ms");
1252
+ // 全部事件同步注册(与 session/event 同步观察者的派发序一致);模拟宿主 KvTable:
1253
+ // put 落盘(异步延迟)完成后才更新内存快照。
1254
+ const serialize = createSessionEventSerializer();
1255
+ const memory = new Map();
1256
+ await Promise.all(
1257
+ events.map((event) =>
1258
+ serialize("session-under-test", async () => {
1259
+ const current = turnStatsFrom(memory.get("session-under-test") ?? null);
1260
+ const next = applyTurnEventToStats(current, event);
1261
+ if (turnStatsEqual(next, current)) return;
1262
+ await new Promise((resolve) => setTimeout(resolve, 5));
1263
+ memory.set("session-under-test", next);
1264
+ }),
1265
+ ),
1266
+ );
1267
+ assert.equal(memory.get("session-under-test")?.busyMs, 4_000 - 11, "R-01-020/AC-02 会话级串行化下并发登记与顺序重放一致");
1268
+ assert.equal(memory.get("session-under-test")?.openTurnStart, null, "R-01-020/AC-02 串行化登记后回合闭合无残留起点");
1269
+ }
1234
1270
  // R-01-020/AC-01
1235
1271
  // R-01-020/AC-03:开放回合实时增量与起点不可得降级。
1236
1272
  assert.equal(totalBusyDisplayMs({ busyMs: 3_500, openTurnStart: 30_000, now: 32_000 }), 5_500, "累计加开放回合实时已耗时");
package/src/core.mjs CHANGED
@@ -2230,6 +2230,32 @@ export function reconcileTurnStats(current, records, { closeOpenTurn = false, fo
2230
2230
  return { ...state, watermarkSeq, watermarkTime };
2231
2231
  }
2232
2232
 
2233
+ /**
2234
+ * 会话级登记串行化(R-01-020/AC-02):宿主存储域的 `KvTable.put` 先异步落盘、落盘完成
2235
+ * 后才更新内存快照,因此同一会话的两个边界事件 handler 并发执行时,后到事件会在先到
2236
+ * 事件落盘前读到旧状态,其效果被当作「无效果」永久丢弃(实测:`ask_user_question` 的
2237
+ * `tool/call` 与 11ms 后的 `tool/result` 交错,等待区间悬挂到 `turn/end` 强制结算,
2238
+ * 总耗时系统性缺记整段运行过程)。返回 `(id, run) => tail`:同一会话前一个 run 完成
2239
+ * (含其 put 的内存生效)后才执行下一个 run,不同会话互不阻塞。run 内部须自行捕获
2240
+ * 错误、不得让拒绝外泄(调用方可安全丢弃返回的 tail);tail 供调用方等待与测试断言。
2241
+ * 链空闲即清理,不随会话数增长。
2242
+ */
2243
+ export function createSessionEventSerializer() {
2244
+ const tails = new Map();
2245
+ return (id, run) => {
2246
+ const prev = tails.get(id) ?? Promise.resolve();
2247
+ const tail = prev.then(run, run);
2248
+ tails.set(id, tail);
2249
+ tail.then(
2250
+ () => {
2251
+ if (tails.get(id) === tail) tails.delete(id);
2252
+ },
2253
+ () => {},
2254
+ );
2255
+ return tail;
2256
+ };
2257
+ }
2258
+
2233
2259
  /**
2234
2260
  * 构建历史区条目:当前非活动的**主会话**,按最后活动时间从新到旧返回完整候选集合。
2235
2261
  * 子代理是临时工作单元,不入历史区;归档、空会话、完成/错误提醒与委托周期中的会话
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 内按子路径分发)。