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.
- package/.dsh-plugin/client.js +26 -0
- package/package.json +1 -1
- package/scripts/check.mjs +36 -0
- package/src/core.mjs +26 -0
- package/src/host.mjs +45 -39
package/.dsh-plugin/client.js
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
await
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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 内按子路径分发)。
|