@pasko70/pibo 1.8.1 → 1.8.2

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.
@@ -10,6 +10,10 @@ export class PiboProviderTelemetryRecorder {
10
10
  this.telemetry = new BestEffortTelemetryService(options.store, options.onError);
11
11
  }
12
12
  recordRequestStart(payload, options = {}) {
13
+ const capturedOptions = { ...options, at: options.at ?? new Date().toISOString() };
14
+ return this.schedule(() => this.recordRequestStartNow(providerPayloadSnapshot(payload), capturedOptions));
15
+ }
16
+ recordRequestStartNow(payload, options) {
13
17
  if (!this.options.store)
14
18
  return undefined;
15
19
  try {
@@ -74,6 +78,10 @@ export class PiboProviderTelemetryRecorder {
74
78
  }
75
79
  }
76
80
  recordResponse(input) {
81
+ const captured = { status: input.status, at: input.at ?? new Date().toISOString() };
82
+ return this.schedule(() => this.recordResponseNow(captured));
83
+ }
84
+ recordResponseNow(input) {
77
85
  if (!this.options.store)
78
86
  return undefined;
79
87
  try {
@@ -129,7 +137,14 @@ export class PiboProviderTelemetryRecorder {
129
137
  // The assistant message boundary ends the provider stream even when the wider
130
138
  // Pibo turn continues with a long-running tool or another provider request.
131
139
  recordMessageEnd(message, options = {}) {
132
- if (!this.options.store || !isAssistantMessage(message))
140
+ if (!isAssistantMessage(message))
141
+ return undefined;
142
+ const captured = providerAssistantMessageSnapshot(message);
143
+ const capturedOptions = { ...options, at: options.at ?? new Date().toISOString() };
144
+ return this.schedule(() => this.recordMessageEndNow(captured, capturedOptions));
145
+ }
146
+ recordMessageEndNow(message, options) {
147
+ if (!this.options.store)
133
148
  return undefined;
134
149
  const status = providerStatusForMessage(message);
135
150
  const summary = providerSummaryForStatus(status);
@@ -144,7 +159,14 @@ export class PiboProviderTelemetryRecorder {
144
159
  return this.finishActiveProviderRequest(status, options.at ?? new Date().toISOString(), summary, errorMessage, errorDetails?.category ?? errorDetails?.errorClass);
145
160
  }
146
161
  recordShutdown(reason, at = new Date().toISOString()) {
147
- return this.finishActiveProviderRequest("aborted", at, reason, undefined, "runtime_abort");
162
+ return this.schedule(() => this.finishActiveProviderRequest("aborted", at, reason, undefined, "runtime_abort"));
163
+ }
164
+ schedule(write) {
165
+ if (this.options.writer) {
166
+ this.options.writer.enqueue(write, this.options.onError);
167
+ return undefined;
168
+ }
169
+ return write();
148
170
  }
149
171
  finishActiveProviderRequest(status, now, summary, errorMessage, errorCategory) {
150
172
  if (!this.options.store)
@@ -301,6 +323,22 @@ function modelFromContext(ctx) {
301
323
  const api = typeof candidate.api === "string" && candidate.api.length > 0 ? candidate.api : undefined;
302
324
  return provider && id ? { provider, id, api } : undefined;
303
325
  }
326
+ function providerAssistantMessageSnapshot(message) {
327
+ return {
328
+ role: message.role,
329
+ stopReason: message.stopReason,
330
+ errorMessage: message.errorMessage,
331
+ api: message.api,
332
+ provider: message.provider,
333
+ model: message.model,
334
+ };
335
+ }
336
+ function providerPayloadSnapshot(payload) {
337
+ return {
338
+ model: modelIdFromPayload(payload),
339
+ service_tier: serviceTierFromPayload(payload),
340
+ };
341
+ }
304
342
  function modelIdFromPayload(payload) {
305
343
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
306
344
  return undefined;
@@ -8,6 +8,7 @@ export class PiboRuntimeTelemetryRecorder {
8
8
  telemetry;
9
9
  providerEventMode;
10
10
  progressFlushIntervalMs;
11
+ writer;
11
12
  pendingProviderProgress = new Map();
12
13
  providerRequestCache = new Map();
13
14
  lastProviderFlushAtMs = new Map();
@@ -17,6 +18,7 @@ export class PiboRuntimeTelemetryRecorder {
17
18
  this.onError = onError;
18
19
  this.telemetry = new BestEffortTelemetryService(store, onError);
19
20
  this.providerEventMode = options.providerEventMode ?? "aggregate";
21
+ this.writer = options.writer;
20
22
  const progressFlushIntervalMs = options.progressFlushIntervalMs;
21
23
  this.progressFlushIntervalMs = typeof progressFlushIntervalMs === "number" && Number.isFinite(progressFlushIntervalMs) && progressFlushIntervalMs >= 0
22
24
  ? progressFlushIntervalMs
@@ -25,31 +27,43 @@ export class PiboRuntimeTelemetryRecorder {
25
27
  recordOutput(event, context = {}) {
26
28
  if (!this.store)
27
29
  return;
28
- try {
29
- this.recordOutputUnsafe(event, context);
30
- }
31
- catch (error) {
32
- this.onError?.(error);
33
- }
30
+ const captured = captureTelemetryContext(context);
31
+ const capturedEvent = telemetryOutputEventSnapshot(event);
32
+ this.schedule(() => this.recordOutputUnsafe(capturedEvent, captured));
34
33
  }
35
34
  recordPiEvent(piboSessionId, event, context = {}) {
36
35
  if (!this.store)
37
36
  return;
38
- try {
39
- this.recordPiEventUnsafe(piboSessionId, event, context);
40
- }
41
- catch (error) {
42
- this.onError?.(error);
43
- }
37
+ const summary = providerEventSummaryForPiEvent(event);
38
+ if (!summary)
39
+ return;
40
+ const captured = captureTelemetryContext(context);
41
+ this.schedule(() => this.recordPiEventSummaryUnsafe(piboSessionId, summary, captured));
44
42
  }
45
43
  recordMessagesInterrupted(messages, context = {}, reason = "message interrupted") {
46
44
  if (!this.store)
47
45
  return;
48
- for (const message of messages) {
49
- if (!message.id)
50
- continue;
51
- this.recordTurnTerminal({ piboSessionId: message.piboSessionId, eventId: message.id }, context, "aborted", "abort", reason, "runtime_abort");
52
- }
46
+ const captured = captureTelemetryContext(context);
47
+ const interrupted = messages.flatMap((message) => message.id ? [{ piboSessionId: message.piboSessionId, eventId: message.id }] : []);
48
+ this.schedule(() => {
49
+ for (const message of interrupted) {
50
+ this.recordTurnTerminal(message, captured, "aborted", "abort", reason, "runtime_abort");
51
+ }
52
+ });
53
+ }
54
+ schedule(write) {
55
+ const guarded = () => {
56
+ try {
57
+ write();
58
+ }
59
+ catch (error) {
60
+ this.onError?.(error);
61
+ }
62
+ };
63
+ if (this.writer)
64
+ this.writer.enqueue(guarded, this.onError);
65
+ else
66
+ guarded();
53
67
  }
54
68
  recordOutputUnsafe(event, context) {
55
69
  switch (event.type) {
@@ -77,7 +91,7 @@ export class PiboRuntimeTelemetryRecorder {
77
91
  case "thinking_finished": {
78
92
  this.recordProviderStreamProgress(event, context, "reasoning", "reasoning finished", true);
79
93
  const turn = this.turnContextForEvent(event.piboSessionId, event.eventId, undefined, context) ?? this.activeTurnContext(event.piboSessionId, context);
80
- this.finishOpenPhasesByName(turn?.turnId, "reasoning", "ok");
94
+ this.finishOpenPhasesByName(turn?.turnId, "reasoning", "ok", telemetryTimestamp(context));
81
95
  return;
82
96
  }
83
97
  case "tool_call":
@@ -104,23 +118,21 @@ export class PiboRuntimeTelemetryRecorder {
104
118
  return;
105
119
  }
106
120
  }
107
- recordPiEventUnsafe(piboSessionId, event, context) {
108
- const summary = providerEventSummaryForPiEvent(event);
109
- if (!summary)
110
- return;
121
+ recordPiEventSummaryUnsafe(piboSessionId, summary, context) {
111
122
  const turn = context.activeEventId
112
123
  ? this.progressTurnContextForEvent(piboSessionId, context.activeEventId, context)
113
124
  : this.activeTurnContext(piboSessionId, context);
114
125
  if (!turn)
115
126
  return;
116
- const now = new Date().toISOString();
127
+ const now = telemetryTimestamp(context);
128
+ const nowMs = telemetryTimestampMs(context);
117
129
  if (summary.assistantEventType === "start") {
118
- this.flushProviderProgress(turn.turnId, now, { force: true, includeLatest: true });
130
+ this.flushProviderProgress(turn.turnId, now, { force: true, includeLatest: true, nowMs });
119
131
  this.clearProviderProgress(turn.turnId);
120
132
  }
121
133
  const providerRequest = this.providerEventMode === "detailed"
122
134
  ? this.providerRequestForTurn(turn.turnId, { includeLatest: summary.messageEnded, refresh: summary.messageEnded })
123
- : this.accumulateProviderEvent(turn, summary, now);
135
+ : this.accumulateProviderEvent(turn, summary, now, nowMs);
124
136
  if (!providerRequest) {
125
137
  if (summary.messageEnded)
126
138
  this.clearProviderProgress(turn.turnId);
@@ -146,11 +158,11 @@ export class PiboRuntimeTelemetryRecorder {
146
158
  }
147
159
  if (summary.toolCallId && summary.assistantEventType?.startsWith("toolcall_")) {
148
160
  const forceToolProgress = summary.assistantEventType !== "toolcall_delta";
149
- if (this.shouldPersistProgress(`${turn.turnId}:tool_args:${summary.toolCallId}`, forceToolProgress)) {
161
+ if (this.shouldPersistProgress(`${turn.turnId}:tool_args:${summary.toolCallId}`, forceToolProgress, nowMs)) {
150
162
  this.recordPiToolCallProgress(turn, providerRequest.providerRequestId, summary, now);
151
163
  }
152
164
  }
153
- if (!summary.messageEnded && !isTerminalProviderStatus(providerRequest.status) && !summary.normalizedType && this.shouldPersistProgress(`${turn.turnId}:provider_stream:${providerRequest.providerRequestId}`)) {
165
+ if (!summary.messageEnded && !isTerminalProviderStatus(providerRequest.status) && !summary.normalizedType && this.shouldPersistProgress(`${turn.turnId}:provider_stream:${providerRequest.providerRequestId}`, false, nowMs)) {
154
166
  this.startOrProgressPhase(turn, "provider_stream", now, "provider event metadata", { providerRequestId: providerRequest.providerRequestId });
155
167
  }
156
168
  if (summary.messageEnded)
@@ -160,7 +172,7 @@ export class PiboRuntimeTelemetryRecorder {
160
172
  const turn = this.turnContextForEvent(event.piboSessionId, event.eventId, event.source, context);
161
173
  if (!turn)
162
174
  return;
163
- const now = new Date().toISOString();
175
+ const now = telemetryTimestamp(context);
164
176
  const queueDepth = event.queuedMessages;
165
177
  this.telemetry.upsertTurn({
166
178
  turnId: turn.turnId,
@@ -197,7 +209,7 @@ export class PiboRuntimeTelemetryRecorder {
197
209
  const turn = this.turnContextForEvent(event.piboSessionId, event.eventId, event.source, context);
198
210
  if (!turn)
199
211
  return;
200
- const now = new Date().toISOString();
212
+ const now = telemetryTimestamp(context);
201
213
  this.telemetry.finishPhase(phaseId(turn.turnId, "queued"), { status: "ok", endedAt: now, lastProgressAt: now });
202
214
  this.telemetry.upsertPhase({
203
215
  phaseId: phaseId(turn.turnId, "message_started"),
@@ -244,10 +256,10 @@ export class PiboRuntimeTelemetryRecorder {
244
256
  : this.activeTurnContext(event.piboSessionId, context);
245
257
  if (!turn)
246
258
  return;
247
- const now = new Date().toISOString();
248
- const providerRequest = this.accumulateNormalizedProviderProgress(turn, now, force);
259
+ const now = telemetryTimestamp(context);
260
+ const providerRequest = this.accumulateNormalizedProviderProgress(turn, now, force, telemetryTimestampMs(context));
249
261
  const progressKey = `${turn.turnId}:${phaseName}:${providerRequest?.providerRequestId ?? "none"}`;
250
- if (!this.shouldPersistProgress(progressKey, force))
262
+ if (!this.shouldPersistProgress(progressKey, force, telemetryTimestampMs(context)))
251
263
  return;
252
264
  const storedTurn = this.store?.getTurn(turn.turnId);
253
265
  if (storedTurn && TERMINAL_TURN_STATUSES.has(storedTurn.status)) {
@@ -256,7 +268,7 @@ export class PiboRuntimeTelemetryRecorder {
256
268
  }
257
269
  this.closeOpenPhasesByName(turn.turnId, "message_started", "ok", now);
258
270
  const providerStreamKey = `${turn.turnId}:provider_stream:${providerRequest?.providerRequestId ?? "none"}`;
259
- if ((!providerRequest || !isTerminalProviderStatus(providerRequest.status)) && this.shouldPersistProgress(providerStreamKey, force)) {
271
+ if ((!providerRequest || !isTerminalProviderStatus(providerRequest.status)) && this.shouldPersistProgress(providerStreamKey, force, telemetryTimestampMs(context))) {
260
272
  this.startOrProgressPhase(turn, "provider_stream", now, "normalized provider stream progress", { providerRequestId: providerRequest?.providerRequestId });
261
273
  }
262
274
  this.startOrProgressPhase(turn, phaseName, now, summary, { updateTurn: true, providerRequestId: providerRequest?.providerRequestId });
@@ -265,11 +277,11 @@ export class PiboRuntimeTelemetryRecorder {
265
277
  const turn = this.turnContextForEvent(event.piboSessionId, event.eventId, undefined, context) ?? this.activeTurnContext(event.piboSessionId, context);
266
278
  if (!turn)
267
279
  return;
268
- const now = new Date().toISOString();
280
+ const now = telemetryTimestamp(context);
269
281
  this.closeOpenPhasesByName(turn.turnId, "message_started", "ok", now);
270
282
  this.closeOpenPhasesByName(turn.turnId, "assistant_text", "ok", now);
271
283
  this.closeOpenPhasesByName(turn.turnId, "reasoning", "ok", now);
272
- const providerRequest = this.accumulateNormalizedProviderProgress(turn, now, true)
284
+ const providerRequest = this.accumulateNormalizedProviderProgress(turn, now, true, telemetryTimestampMs(context))
273
285
  ?? this.providerRequestForTurn(turn.turnId, { includeLatest: true });
274
286
  this.upsertToolCallArgs(turn, {
275
287
  toolCallId: event.toolCallId,
@@ -295,14 +307,14 @@ export class PiboRuntimeTelemetryRecorder {
295
307
  : this.activeTurnContext(event.piboSessionId, context);
296
308
  if (!turn)
297
309
  return;
298
- if (!this.shouldPersistProgress(`${turn.turnId}:tool_execution:${event.toolCallId}`, force))
310
+ if (!this.shouldPersistProgress(`${turn.turnId}:tool_execution:${event.toolCallId}`, force, telemetryTimestampMs(context)))
299
311
  return;
300
312
  const storedTurn = this.store?.getTurn(turn.turnId);
301
313
  if (storedTurn && TERMINAL_TURN_STATUSES.has(storedTurn.status)) {
302
314
  this.clearTurnProgress(turn.turnId);
303
315
  return;
304
316
  }
305
- const now = new Date().toISOString();
317
+ const now = telemetryTimestamp(context);
306
318
  const existing = this.store?.getToolCall(event.toolCallId);
307
319
  const args = toolArgsMetadata(event.args, true);
308
320
  const providerRequestId = existing?.providerRequestId ?? this.latestProviderRequestForTurn(turn.turnId)?.providerRequestId;
@@ -336,7 +348,7 @@ export class PiboRuntimeTelemetryRecorder {
336
348
  const turn = this.turnContextForEvent(event.piboSessionId, event.eventId, undefined, context) ?? this.activeTurnContext(event.piboSessionId, context);
337
349
  if (!turn)
338
350
  return;
339
- const now = new Date().toISOString();
351
+ const now = telemetryTimestamp(context);
340
352
  const existing = this.store?.getToolCall(event.toolCallId);
341
353
  const executionStartedAt = existing?.executionStartedAt;
342
354
  this.telemetry.upsertToolCall({
@@ -425,8 +437,8 @@ export class PiboRuntimeTelemetryRecorder {
425
437
  this.clearTurnProgress(turn.turnId);
426
438
  return;
427
439
  }
428
- const now = new Date().toISOString();
429
- this.flushProviderProgress(turn.turnId, now, { force: true, includeLatest: true });
440
+ const now = telemetryTimestamp(context);
441
+ this.flushProviderProgress(turn.turnId, now, { force: true, includeLatest: true, nowMs: telemetryTimestampMs(context) });
430
442
  this.finishOpenPhases(turn.turnId, terminalPhaseStatus(status), now);
431
443
  this.finishActiveProviderRequests(turn.turnId, providerStatusForTurnStatus(status), now, summary, errorCategory);
432
444
  this.finishActiveToolCalls(turn.turnId, status, now, summary);
@@ -540,7 +552,7 @@ export class PiboRuntimeTelemetryRecorder {
540
552
  this.providerRequestCache.delete(turnId);
541
553
  return request;
542
554
  }
543
- accumulateProviderEvent(turn, summary, now) {
555
+ accumulateProviderEvent(turn, summary, now, nowMs) {
544
556
  const pending = this.pendingProviderProgress.get(turn.turnId) ?? emptyPendingProviderProgress();
545
557
  pending.lastRawEventAt = now;
546
558
  pending.upstreamResponseId = summary.upstreamResponseId ?? pending.upstreamResponseId;
@@ -554,9 +566,10 @@ export class PiboRuntimeTelemetryRecorder {
554
566
  force: summary.messageEnded,
555
567
  includeLatest: summary.messageEnded,
556
568
  refresh: summary.messageEnded,
569
+ nowMs,
557
570
  });
558
571
  }
559
- accumulateNormalizedProviderProgress(turn, now, force = false) {
572
+ accumulateNormalizedProviderProgress(turn, now, force = false, nowMs = Date.now()) {
560
573
  const request = this.providerRequestForTurn(turn.turnId, { includeLatest: force });
561
574
  if (!request)
562
575
  return undefined;
@@ -565,14 +578,14 @@ export class PiboRuntimeTelemetryRecorder {
565
578
  pending.normalizedEventCount += 1;
566
579
  this.pendingProviderProgress.set(turn.turnId, pending);
567
580
  const flushNow = force || request.status === "started" || request.status === "headers";
568
- return this.flushProviderProgress(turn.turnId, now, { force: flushNow, includeLatest: force }) ?? request;
581
+ return this.flushProviderProgress(turn.turnId, now, { force: flushNow, includeLatest: force, nowMs }) ?? request;
569
582
  }
570
583
  flushProviderProgress(turnId, now, options = {}) {
571
584
  const pending = this.pendingProviderProgress.get(turnId);
572
585
  const request = this.providerRequestForTurn(turnId, options);
573
586
  if (!pending)
574
587
  return request;
575
- const nowMs = Date.now();
588
+ const nowMs = options.nowMs ?? Date.now();
576
589
  const lastFlushAtMs = this.lastProviderFlushAtMs.get(turnId);
577
590
  if (!options.force && lastFlushAtMs !== undefined && nowMs - lastFlushAtMs < this.progressFlushIntervalMs)
578
591
  return request;
@@ -602,8 +615,7 @@ export class PiboRuntimeTelemetryRecorder {
602
615
  this.providerRequestCache.set(turnId, updated);
603
616
  return updated ?? request;
604
617
  }
605
- shouldPersistProgress(key, force = false) {
606
- const nowMs = Date.now();
618
+ shouldPersistProgress(key, force = false, nowMs = Date.now()) {
607
619
  const lastWriteAtMs = this.lastProgressWriteAtMs.get(key);
608
620
  if (!force && lastWriteAtMs !== undefined && nowMs - lastWriteAtMs < this.progressFlushIntervalMs)
609
621
  return false;
@@ -960,6 +972,32 @@ function safeJsonByteSize(value) {
960
972
  function utf8Bytes(value) {
961
973
  return Buffer.byteLength(value, "utf8");
962
974
  }
975
+ function telemetryOutputEventSnapshot(event) {
976
+ if (event.type === "tool_execution_updated")
977
+ return { ...event, partialResult: undefined };
978
+ if (event.type === "tool_execution_finished")
979
+ return { ...event, result: event.isError ? safeErrorMessage(event.result) : undefined };
980
+ if (event.type === "execution_result")
981
+ return { ...event, result: undefined };
982
+ return { ...event };
983
+ }
984
+ function captureTelemetryContext(context) {
985
+ const parsedAtMs = context.at ? Date.parse(context.at) : Number.NaN;
986
+ const atMs = context.atMs ?? (Number.isFinite(parsedAtMs) ? parsedAtMs : Date.now());
987
+ return {
988
+ ...context,
989
+ session: context.session ? { ...context.session, metadata: context.session.metadata ? { ...context.session.metadata } : undefined } : undefined,
990
+ status: context.status ? { ...context.status, activeTools: [...context.status.activeTools], enabledTools: [...context.status.enabledTools] } : undefined,
991
+ at: context.at ?? new Date(atMs).toISOString(),
992
+ atMs,
993
+ };
994
+ }
995
+ function telemetryTimestamp(context) {
996
+ return context.at ?? new Date().toISOString();
997
+ }
998
+ function telemetryTimestampMs(context) {
999
+ return context.atMs ?? Date.now();
1000
+ }
963
1001
  export function turnIdForEvent(eventId) {
964
1002
  return `turn_${eventId}`;
965
1003
  }
@@ -19,6 +19,7 @@ import { assertGatewayResourceAvailableForWork } from "./gateway-resource-guard.
19
19
  import { withWorkflowSessionKind } from "../sessions/workflow-session-kind.js";
20
20
  import { PiboRuntimeTelemetryRecorder } from "./runtime-telemetry.js";
21
21
  import { createPiboProviderTelemetryExtension } from "./provider-telemetry.js";
22
+ import { AsyncTelemetryWriter } from "../data/telemetry-writer.js";
22
23
  const DEFAULT_SUBAGENT_REPLY_TIMEOUT_MS = 10 * 60 * 1000;
23
24
  const DEFAULT_ROUTED_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
24
25
  export const RALPH_RUNTIME_RETRY_DEFAULTS = {
@@ -140,14 +141,21 @@ export class PiboSessionRouter {
140
141
  sessionStore;
141
142
  reliabilityStore;
142
143
  telemetryStore;
144
+ telemetryWriter;
143
145
  telemetryRecorder;
146
+ disposePromise;
147
+ closing = false;
144
148
  constructor(options = {}) {
145
149
  this.options = options;
146
150
  this.pluginRegistry = options.pluginRegistry ?? createDefaultPiboPluginRegistry();
147
151
  this.sessionStore = options.sessionStore ?? new InMemoryPiboSessionStore();
148
152
  this.telemetryStore = options.telemetryStore ?? telemetryStoreFromSessionStore(this.sessionStore);
153
+ this.telemetryWriter = this.telemetryStore ? new AsyncTelemetryWriter(this.telemetryStore) : undefined;
149
154
  this.telemetryRecorder = this.telemetryStore
150
- ? new PiboRuntimeTelemetryRecorder(this.telemetryStore, undefined, { providerEventMode: providerEventTelemetryModeFromEnv() })
155
+ ? new PiboRuntimeTelemetryRecorder(this.telemetryStore, undefined, {
156
+ providerEventMode: providerEventTelemetryModeFromEnv(),
157
+ writer: this.telemetryWriter,
158
+ })
151
159
  : undefined;
152
160
  const idleTimeoutMs = options.routedSessionIdleTimeoutMs;
153
161
  this.routedSessionIdleTimeoutMs = idleTimeoutMs === false
@@ -173,6 +181,8 @@ export class PiboSessionRouter {
173
181
  };
174
182
  }
175
183
  async emit(event) {
184
+ if (this.closing)
185
+ throw new Error("Pibo session router is disposed.");
176
186
  const session = await this.getOrCreateSession(event.piboSessionId);
177
187
  this.clearIdleSessionTimer(event.piboSessionId);
178
188
  try {
@@ -229,8 +239,13 @@ export class PiboSessionRouter {
229
239
  sessions.push(cached);
230
240
  this.sessions.delete(id);
231
241
  }
232
- await Promise.all(ids.map((id) => this.runtimeRegistry.closeControllerSessions(id, { force: true })));
233
- await Promise.all(sessions.map((session) => session.dispose()));
242
+ try {
243
+ await Promise.all(ids.map((id) => this.runtimeRegistry.closeControllerSessions(id, { force: true })));
244
+ await Promise.all(sessions.map((session) => session.dispose()));
245
+ }
246
+ finally {
247
+ await this.telemetryWriter?.flush();
248
+ }
234
249
  for (const id of ids) {
235
250
  this.signalRegistry.project({ type: "session_disposed", piboSessionId: id, reason });
236
251
  }
@@ -369,17 +384,30 @@ export class PiboSessionRouter {
369
384
  });
370
385
  }
371
386
  async disposeAll() {
372
- const sessions = [...this.sessions.values()];
373
- this.sessions.clear();
374
- for (const timer of this.idleSessionTimers.values())
375
- clearTimeout(timer);
376
- this.idleSessionTimers.clear();
377
- this.runRegistry.cancelAll("Pibo session router was disposed.");
378
- for (const session of sessions)
379
- this.signalRegistry.project({ type: "session_disposed", piboSessionId: session.getStatus().piboSessionId, reason: "router disposed" });
380
- this.scheduledRunReminders.clear();
381
- await this.runtimeRegistry.closeAll({ force: true });
382
- await Promise.all(sessions.map((session) => session.dispose()));
387
+ if (this.disposePromise)
388
+ return this.disposePromise;
389
+ this.closing = true;
390
+ this.disposePromise = this.disposeAllUnsafe();
391
+ return this.disposePromise;
392
+ }
393
+ async disposeAllUnsafe() {
394
+ try {
395
+ await Promise.allSettled([...this.pendingSessions.values()]);
396
+ const sessions = [...this.sessions.values()];
397
+ this.sessions.clear();
398
+ for (const timer of this.idleSessionTimers.values())
399
+ clearTimeout(timer);
400
+ this.idleSessionTimers.clear();
401
+ this.runRegistry.cancelAll("Pibo session router was disposed.");
402
+ for (const session of sessions)
403
+ this.signalRegistry.project({ type: "session_disposed", piboSessionId: session.getStatus().piboSessionId, reason: "router disposed" });
404
+ this.scheduledRunReminders.clear();
405
+ await this.runtimeRegistry.closeAll({ force: true });
406
+ await Promise.all(sessions.map((session) => session.dispose()));
407
+ }
408
+ finally {
409
+ await this.telemetryWriter?.dispose();
410
+ }
383
411
  }
384
412
  clearIdleSessionTimer(piboSessionId) {
385
413
  const timer = this.idleSessionTimers.get(piboSessionId);
@@ -424,6 +452,8 @@ export class PiboSessionRouter {
424
452
  await this.resetCachedSession(piboSessionId, "routed runtime idle timeout");
425
453
  }
426
454
  async getOrCreateSession(piboSessionId) {
455
+ if (this.closing)
456
+ throw new Error("Pibo session router is disposed.");
427
457
  const existing = this.sessions.get(piboSessionId);
428
458
  if (existing) {
429
459
  this.clearIdleSessionTimer(piboSessionId);
@@ -453,7 +483,7 @@ export class PiboSessionRouter {
453
483
  const initialThinkingLevel = resolvePiboSessionInitialThinkingLevel(piboSession);
454
484
  const userSettings = loadPiboUserSettings();
455
485
  const telemetryExtension = this.telemetryStore
456
- ? createPiboProviderTelemetryExtension({ store: this.telemetryStore, session: piboSession, model: activeModel })
486
+ ? createPiboProviderTelemetryExtension({ store: this.telemetryStore, writer: this.telemetryWriter, session: piboSession, model: activeModel })
457
487
  : undefined;
458
488
  const runtime = await createPiboRuntime({
459
489
  cwd: piboSession.workspace ?? this.options.cwd,
@@ -549,8 +579,13 @@ export class PiboSessionRouter {
549
579
  const cached = this.sessions.get(piboSessionId);
550
580
  this.clearIdleSessionTimer(piboSessionId);
551
581
  this.sessions.delete(piboSessionId);
552
- await this.runtimeRegistry.closeControllerSessions(piboSessionId, { force: true });
553
- await cached?.dispose();
582
+ try {
583
+ await this.runtimeRegistry.closeControllerSessions(piboSessionId, { force: true });
584
+ await cached?.dispose();
585
+ }
586
+ finally {
587
+ await this.telemetryWriter?.flush();
588
+ }
554
589
  if (reason)
555
590
  this.signalRegistry.project({ type: "session_disposed", piboSessionId, reason });
556
591
  }
@@ -0,0 +1,114 @@
1
+ const DEFAULT_FLUSH_INTERVAL_MS = 25;
2
+ const DEFAULT_MAX_PENDING_OPERATIONS = 1_024;
3
+ /**
4
+ * Gateway-scoped, ordered telemetry writer.
5
+ *
6
+ * Normal writes are deferred briefly so telemetry from multiple routed sessions
7
+ * shares one SQLite transaction. The queue never drops lifecycle events: when
8
+ * the hard bound is reached, it drains immediately in the caller instead.
9
+ */
10
+ export class AsyncTelemetryWriter {
11
+ store;
12
+ options;
13
+ flushIntervalMs;
14
+ maxPendingOperations;
15
+ pending = [];
16
+ flushTimer;
17
+ flushing = false;
18
+ closed = false;
19
+ constructor(store, options = {}) {
20
+ this.store = store;
21
+ this.options = options;
22
+ this.flushIntervalMs = nonNegativeFinite(options.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS);
23
+ this.maxPendingOperations = positiveInteger(options.maxPendingOperations, DEFAULT_MAX_PENDING_OPERATIONS);
24
+ }
25
+ enqueue(write, onError) {
26
+ if (this.closed) {
27
+ this.reportError(new Error("Telemetry writer is closed."), onError);
28
+ return false;
29
+ }
30
+ this.pending.push({ write, onError });
31
+ if (this.pending.length >= this.maxPendingOperations) {
32
+ this.flushNow();
33
+ }
34
+ else {
35
+ this.scheduleFlush();
36
+ }
37
+ return true;
38
+ }
39
+ async flush() {
40
+ this.flushNow();
41
+ }
42
+ async dispose() {
43
+ if (this.closed)
44
+ return;
45
+ this.flushNow();
46
+ this.closed = true;
47
+ }
48
+ scheduleFlush() {
49
+ if (this.flushTimer)
50
+ return;
51
+ this.flushTimer = setTimeout(() => {
52
+ this.flushTimer = undefined;
53
+ this.flushNow();
54
+ }, this.flushIntervalMs);
55
+ this.flushTimer.unref();
56
+ }
57
+ flushNow() {
58
+ if (this.flushing)
59
+ return;
60
+ if (this.flushTimer)
61
+ clearTimeout(this.flushTimer);
62
+ this.flushTimer = undefined;
63
+ this.flushing = true;
64
+ try {
65
+ while (this.pending.length > 0) {
66
+ const batch = this.pending;
67
+ this.pending = [];
68
+ try {
69
+ this.store.transaction(() => {
70
+ for (const operation of batch) {
71
+ try {
72
+ operation.write();
73
+ }
74
+ catch (error) {
75
+ this.reportError(error, operation.onError);
76
+ }
77
+ }
78
+ });
79
+ }
80
+ catch (error) {
81
+ for (const operation of batch)
82
+ this.reportError(error, operation.onError);
83
+ }
84
+ }
85
+ }
86
+ finally {
87
+ this.flushing = false;
88
+ if (!this.closed && this.pending.length > 0)
89
+ this.scheduleFlush();
90
+ }
91
+ }
92
+ reportError(error, operationHandler) {
93
+ try {
94
+ operationHandler?.(error);
95
+ }
96
+ catch {
97
+ // Telemetry error reporting must not affect runtime work.
98
+ }
99
+ if (operationHandler === this.options.onError)
100
+ return;
101
+ try {
102
+ this.options.onError?.(error);
103
+ }
104
+ catch {
105
+ // Telemetry error reporting must not affect runtime work.
106
+ }
107
+ }
108
+ }
109
+ function nonNegativeFinite(value, fallback) {
110
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
111
+ }
112
+ function positiveInteger(value, fallback) {
113
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
114
+ }
@@ -8,6 +8,20 @@ export class TelemetryStore {
8
8
  constructor(db) {
9
9
  this.db = db;
10
10
  }
11
+ transaction(action) {
12
+ if (this.db.isTransaction)
13
+ return action();
14
+ this.db.exec("BEGIN IMMEDIATE");
15
+ try {
16
+ const result = action();
17
+ this.db.exec("COMMIT");
18
+ return result;
19
+ }
20
+ catch (error) {
21
+ this.db.exec("ROLLBACK");
22
+ throw error;
23
+ }
24
+ }
11
25
  listSessions(input = {}) {
12
26
  return listTelemetrySessions(this.db, input);
13
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "type": "module",
5
5
  "imports": {
6
6
  "vscode": "./src/apps/chat-vscode/extension/src/vscode-shim.js"