@pasko70/pibo 3.5.0 → 3.5.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.
Files changed (46) hide show
  1. package/dist/agent-runtime/routed-session.js +80 -19
  2. package/dist/agent-runtimes/codex-native/turn.js +27 -3
  3. package/dist/apps/chat/data/chat-data-mappers.js +8 -1
  4. package/dist/apps/chat/data/read-state-service.js +24 -3
  5. package/dist/apps/chat/message-command-dispatcher.js +16 -1
  6. package/dist/apps/chat/web-app.js +40 -19
  7. package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-B-auLrzD.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-BA_dsINH.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-eJZar_0-.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-wNNR2Bci.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-zcmEsIEp.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-DEkbN5Vo.js} +43 -43
  13. package/dist/apps/chat-ui/assets/index-DZK6Tzil.css +1 -0
  14. package/dist/apps/chat-ui/index.html +2 -2
  15. package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DZgW1fCB.js} +11 -11
  16. package/dist/apps/chat-vscode-web/index.html +1 -1
  17. package/dist/cli-session/localSessionSource.js +3 -2
  18. package/dist/core/output-render-sequence.js +63 -6
  19. package/dist/core/session-router.js +99 -11
  20. package/dist/data/async-chat-storage.js +7 -2
  21. package/dist/data/bounded-worker-client.js +1 -1
  22. package/dist/data/chat-read-projections.js +4 -4
  23. package/dist/data/chat-storage-worker.js +24 -3
  24. package/dist/data/ingest-service.js +73 -4
  25. package/dist/data/message-command-store.js +153 -11
  26. package/dist/data/schema.js +24 -3
  27. package/dist/data/storage-maintenance.js +344 -0
  28. package/dist/data/storage-verification-worker.js +25 -0
  29. package/dist/debug/index.js +155 -1
  30. package/dist/debug/message-queue.js +108 -0
  31. package/dist/debug/output-collision-repair.js +140 -0
  32. package/dist/debug/output-integrity.js +38 -2
  33. package/dist/debug/output-repair.js +1 -0
  34. package/dist/debug/storage-backup.js +12 -4
  35. package/dist/debug/storage-maintenance.js +78 -0
  36. package/dist/gateway/cli.js +71 -7
  37. package/dist/gateway/server.js +1 -0
  38. package/dist/reliability/store.js +119 -23
  39. package/dist/session-ui/terminalRows.js +7 -8
  40. package/dist/sessions/pibo-data-store.js +18 -14
  41. package/dist/shared/trace-event-projection.js +13 -3
  42. package/dist/web/channel.js +114 -12
  43. package/dist/web/http.js +105 -44
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +1 -1
  46. package/dist/apps/chat-ui/assets/index-hEkrlRk-.css +0 -1
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="theme-color" content="#101d22" />
7
7
  <title>Pibo</title>
8
- <script type="module" crossorigin src="/apps/chat-vscode/assets/index-xacbCyTx.js"></script>
8
+ <script type="module" crossorigin src="/apps/chat-vscode/assets/index-DZgW1fCB.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/apps/chat-vscode/assets/index-b18ZkEo0.css">
10
10
  </head>
11
11
  <body>
@@ -641,7 +641,7 @@ export class LocalCliSessionSource {
641
641
  if (delivery.persisted)
642
642
  continue;
643
643
  try {
644
- this.ingestOutputEvent(session, delivery.event);
644
+ this.ingestOutputEvent(session, delivery.event, retryContext.attempt > 1 ? "durable-replay" : "live");
645
645
  }
646
646
  catch (error) {
647
647
  errors.push(error instanceof Error ? error : new Error(String(error)));
@@ -753,7 +753,7 @@ export class LocalCliSessionSource {
753
753
  // Persistence is best-effort; live local rendering still proceeds through router events.
754
754
  }
755
755
  }
756
- ingestOutputEvent(session, event) {
756
+ ingestOutputEvent(session, event, phase) {
757
757
  if (!this.ingestService)
758
758
  return;
759
759
  this.ingestService.ingestOutputEvent({
@@ -762,6 +762,7 @@ export class LocalCliSessionSource {
762
762
  actorId: session.profile,
763
763
  event,
764
764
  createdAt: this.now(),
765
+ persistenceProvenance: { producer: "local-cli", projection: "product-history", phase },
765
766
  });
766
767
  }
767
768
  buildStatus(session) {
@@ -109,9 +109,10 @@ export class OutputRenderSequencer {
109
109
  const suppliedPart = transition.suppliedIndex === undefined
110
110
  ? undefined
111
111
  : parts.find((part) => part.index === transition.suppliedIndex);
112
- if (transition.suppliedIndex !== undefined && (transition.canonicalIndex || suppliedPart)) {
112
+ if (transition.suppliedIndex !== undefined && (transition.canonicalIndex
113
+ || (suppliedPart && (!suppliedPart.closed || suppliedPart.identityFingerprint === transition.identityFingerprint)))) {
113
114
  this.highWaterStore?.observeOutputPart?.({ ...transition, index: transition.suppliedIndex });
114
- this.recordOutputPart(parts, transition.suppliedIndex, transition.terminal);
115
+ this.recordOutputPart(parts, transition.suppliedIndex, transition.terminal, transition.identityFingerprint);
115
116
  this.trimOutputParts(state);
116
117
  return event;
117
118
  }
@@ -122,18 +123,20 @@ export class OutputRenderSequencer {
122
123
  const index = latestOpen
123
124
  ? localIndex
124
125
  : this.highWaterStore?.claimOrAttachOutputPart?.({ ...transition, proposedIndex: localIndex }) ?? localIndex;
125
- this.recordOutputPart(parts, index, transition.terminal);
126
+ this.recordOutputPart(parts, index, transition.terminal, transition.identityFingerprint);
126
127
  this.trimOutputParts(state);
127
128
  return withOutputPartIndex(event, transition.kind, index);
128
129
  }
129
- recordOutputPart(parts, index, terminal) {
130
+ recordOutputPart(parts, index, terminal, identityFingerprint) {
130
131
  let part = parts.find((candidate) => candidate.index === index);
131
132
  if (!part) {
132
- part = { index, closed: false };
133
+ part = { index, closed: false, identityFingerprint };
133
134
  parts.push(part);
134
135
  }
135
- if (terminal)
136
+ if (terminal) {
136
137
  part.closed = true;
138
+ part.identityFingerprint = identityFingerprint;
139
+ }
137
140
  }
138
141
  trimOutputParts(state) {
139
142
  let total = 0;
@@ -380,12 +383,66 @@ function outputPartTransition(event, activeEventId) {
380
383
  function validOutputPartIndex(value) {
381
384
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
382
385
  }
386
+ export const OUTPUT_IDENTITY_FINGERPRINT_VERSION = 2;
383
387
  export function outputIdentityFingerprint(event) {
388
+ return createHash("sha256").update(stableJson(outputIdentityPayload(event))).digest("hex");
389
+ }
390
+ /** Exact v1 algorithm retained only for comparing pre-v2 persisted fingerprints. */
391
+ export function legacyOutputIdentityFingerprint(event) {
384
392
  const payload = { ...event };
385
393
  delete payload.renderSequence;
386
394
  delete payload.compactionStats;
387
395
  return createHash("sha256").update(stableJson(payload)).digest("hex");
388
396
  }
397
+ export function legacyOutputIdentityFingerprintCandidates(event) {
398
+ const variants = [{ ...event }];
399
+ const assistant = event.type === "assistant_delta" || event.type === "assistant_message";
400
+ const thinking = event.type === "thinking_started" || event.type === "thinking_delta" || event.type === "thinking_finished";
401
+ const index = assistant ? event.assistantIndex ?? event.contentIndex : thinking ? event.thinkingIndex ?? event.contentIndex : undefined;
402
+ if (index !== undefined && (assistant || thinking)) {
403
+ for (const attribute of assistant ? ["assistantIndex", "contentIndex"] : ["thinkingIndex", "contentIndex"]) {
404
+ const variant = { ...event };
405
+ delete variant.assistantIndex;
406
+ delete variant.thinkingIndex;
407
+ delete variant.contentIndex;
408
+ variant[attribute] = index;
409
+ variants.push(variant);
410
+ }
411
+ }
412
+ for (const variant of [...variants]) {
413
+ if ("provenance" in variant) {
414
+ const withoutProvenance = { ...variant };
415
+ delete withoutProvenance.provenance;
416
+ variants.push(withoutProvenance);
417
+ }
418
+ }
419
+ return [...new Set(variants.map((variant) => legacyOutputIdentityFingerprint(variant)))];
420
+ }
421
+ /** Redacted, bounded evidence for diagnosing a fingerprint mismatch without retaining values. */
422
+ export function outputIdentityFieldDigests(event) {
423
+ return Object.fromEntries(Object.entries(outputIdentityPayload(event))
424
+ .slice(0, 64)
425
+ .map(([field, value]) => [field, createHash("sha256").update(stableJson(value)).digest("hex").slice(0, 16)]));
426
+ }
427
+ function outputIdentityPayload(event) {
428
+ const payload = { ...event };
429
+ delete payload.renderSequence;
430
+ delete payload.compactionStats;
431
+ // Delivery provenance describes how equivalent output reached persistence; it
432
+ // is diagnostic metadata rather than semantic message content.
433
+ delete payload.provenance;
434
+ if (event.type === "assistant_delta" || event.type === "assistant_message") {
435
+ delete payload.assistantIndex;
436
+ delete payload.contentIndex;
437
+ payload.outputPartIndex = event.assistantIndex ?? event.contentIndex ?? 0;
438
+ }
439
+ else if (event.type === "thinking_started" || event.type === "thinking_delta" || event.type === "thinking_finished") {
440
+ delete payload.thinkingIndex;
441
+ delete payload.contentIndex;
442
+ payload.outputPartIndex = event.thinkingIndex ?? event.contentIndex ?? 0;
443
+ }
444
+ return payload;
445
+ }
389
446
  export function outputPartFingerprint(event) {
390
447
  const payload = { ...event };
391
448
  delete payload.renderSequence;
@@ -175,6 +175,39 @@ function formatRunReminderMessage(notification) {
175
175
  function isRunReminderServiceMessage(event) {
176
176
  return event.source === "service" && event.text.startsWith("<pibo_run_notification>");
177
177
  }
178
+ const RUNTIME_QUEUE_CAPACITY_DIMENSIONS = new Set([
179
+ "message_bytes",
180
+ "queue_count",
181
+ "queue_bytes",
182
+ "oldest_wait_age",
183
+ ]);
184
+ function runReminderAdmissionDiagnostic(error) {
185
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "runtime_capacity_unavailable") {
186
+ return {
187
+ code: "run_reminder_delivery_unavailable",
188
+ warning: "Run reminder deferred: internal delivery is temporarily unavailable; pending run state is retained.",
189
+ };
190
+ }
191
+ const candidate = error;
192
+ const dimension = typeof candidate.dimension === "string"
193
+ && RUNTIME_QUEUE_CAPACITY_DIMENSIONS.has(candidate.dimension)
194
+ ? candidate.dimension
195
+ : undefined;
196
+ const current = candidate.current
197
+ && ["messageBytes", "queueCount", "queueBytes", "oldestWaitMs"].every((key) => (typeof candidate.current?.[key] === "number" && Number.isFinite(candidate.current[key]) && candidate.current[key] >= 0))
198
+ ? candidate.current
199
+ : undefined;
200
+ const limit = typeof candidate.limit === "number" && Number.isFinite(candidate.limit) && candidate.limit >= 0
201
+ ? candidate.limit
202
+ : undefined;
203
+ return {
204
+ code: "runtime_capacity_unavailable",
205
+ warning: `Run reminder deferred: runtime queue${dimension ? ` ${dimension}` : ""} capacity is unavailable; pending run state is retained.`,
206
+ ...(dimension ? { dimension } : {}),
207
+ ...(current ? { current } : {}),
208
+ ...(limit !== undefined ? { limit } : {}),
209
+ };
210
+ }
178
211
  function isRunReminderContextPressureError(event) {
179
212
  return event.errorDetails?.category === "context_overflow"
180
213
  || event.errorDetails?.code === "context_length_exceeded"
@@ -365,6 +398,7 @@ export class PiboSessionRouter {
365
398
  deferredRunReminders = new Map();
366
399
  runReminderRecoveries = new Map();
367
400
  runReminderGenerations = new Map();
401
+ runReminderAdmissionWarnings = new Map();
368
402
  runCancellationHandlers = new Map();
369
403
  activeRunExecutions = new Set();
370
404
  quiescingSessions = new Set();
@@ -1038,6 +1072,13 @@ export class PiboSessionRouter {
1038
1072
  listRuns(options = {}) {
1039
1073
  return this.runRegistry.listAll(options);
1040
1074
  }
1075
+ getRunJobReliabilityStatus() {
1076
+ return this.reliabilityStore?.getRunJobReliabilityStatus() ?? {
1077
+ status: "ok",
1078
+ expiredOrphanRunJobs: 0,
1079
+ orphanRunDeadLetters: 0,
1080
+ };
1081
+ }
1041
1082
  getSignalRegistry() {
1042
1083
  return this.signalRegistry;
1043
1084
  }
@@ -1630,6 +1671,7 @@ export class PiboSessionRouter {
1630
1671
  },
1631
1672
  statusResources,
1632
1673
  getToolMetricTokenCalculation: () => loadPiboUserSettings().toolMetrics.tokenCalculation,
1674
+ now: this.options.runtimeQueueNow,
1633
1675
  });
1634
1676
  this.sessions.set(piboSession.id, session);
1635
1677
  return session;
@@ -2661,6 +2703,7 @@ export class PiboSessionRouter {
2661
2703
  this.runRegistry.suppressNotification(delivery.piboSessionId, run.runId);
2662
2704
  this.clearRunReminderRecovery(delivery);
2663
2705
  this.deferredRunReminders.delete(delivery.piboSessionId);
2706
+ this.clearRunReminderAdmissionWarning(delivery.piboSessionId, delivery.generation);
2664
2707
  this.sessions.get(delivery.piboSessionId)?.removeQueuedMessages(isRunReminderServiceMessage);
2665
2708
  this.scheduleRunReminder(delivery.piboSessionId, false, delivery.generation);
2666
2709
  }
@@ -2750,6 +2793,7 @@ export class PiboSessionRouter {
2750
2793
  this.scheduledRunReminders.delete(piboSessionId);
2751
2794
  this.deferredRunReminders.delete(piboSessionId);
2752
2795
  this.runReminderRecoveries.delete(piboSessionId);
2796
+ this.clearRunReminderAdmissionWarning(piboSessionId);
2753
2797
  for (const [eventId, delivery] of this.runReminderDeliveries) {
2754
2798
  if (delivery.piboSessionId === piboSessionId)
2755
2799
  this.runReminderDeliveries.delete(eventId);
@@ -2787,8 +2831,18 @@ export class PiboSessionRouter {
2787
2831
  return;
2788
2832
  if (deferredGeneration !== undefined)
2789
2833
  this.deferredRunReminders.delete(piboSessionId);
2790
- if (!this.runRegistry.hasPendingNotification(piboSessionId, { includeAlreadyNotified }))
2834
+ if (!this.runRegistry.hasPendingNotification(piboSessionId, { includeAlreadyNotified })) {
2835
+ this.clearRunReminderAdmissionWarning(piboSessionId, expectedGeneration);
2791
2836
  return;
2837
+ }
2838
+ if (includeAlreadyNotified) {
2839
+ const status = this.sessions.get(piboSessionId)?.getStatus?.();
2840
+ const effectiveEventIds = new Set([status?.activeEventId, ...(status?.queuedEventIds ?? [])].filter((id) => Boolean(id)));
2841
+ if ([...this.runReminderDeliveries.entries()].some(([eventId, delivery]) => (effectiveEventIds.has(eventId)
2842
+ && delivery.piboSessionId === piboSessionId
2843
+ && delivery.generation === expectedGeneration)))
2844
+ return;
2845
+ }
2792
2846
  const previous = this.scheduledRunReminders.get(piboSessionId);
2793
2847
  if (previous?.generation === expectedGeneration) {
2794
2848
  this.scheduledRunReminders.set(piboSessionId, {
@@ -2804,8 +2858,33 @@ export class PiboSessionRouter {
2804
2858
  }
2805
2859
  refreshQueuedRunReminders(piboSessionId) {
2806
2860
  const removed = this.sessions.get(piboSessionId)?.removeQueuedMessages(isRunReminderServiceMessage) ?? 0;
2807
- if (removed > 0)
2861
+ if (removed > 0) {
2808
2862
  this.scheduleRunReminder(piboSessionId, true);
2863
+ }
2864
+ else if (!this.runRegistry.hasPendingNotification(piboSessionId, { includeAlreadyNotified: true })) {
2865
+ this.clearRunReminderAdmissionWarning(piboSessionId);
2866
+ }
2867
+ }
2868
+ clearRunReminderAdmissionWarning(piboSessionId, generation) {
2869
+ if (generation !== undefined && this.runReminderAdmissionWarnings.get(piboSessionId) !== generation)
2870
+ return;
2871
+ this.runReminderAdmissionWarnings.delete(piboSessionId);
2872
+ this.sessions.get(piboSessionId)?.setRunReminderDeferredWarning?.(undefined);
2873
+ }
2874
+ deferRunReminderAdmission(piboSessionId, generation, error) {
2875
+ const diagnostic = runReminderAdmissionDiagnostic(error);
2876
+ this.sessions.get(piboSessionId)?.setRunReminderDeferredWarning?.(diagnostic.warning);
2877
+ if (this.runReminderAdmissionWarnings.get(piboSessionId) === generation)
2878
+ return;
2879
+ this.runReminderAdmissionWarnings.set(piboSessionId, generation);
2880
+ console.warn("[pibo] run reminder deferred", {
2881
+ piboSessionId,
2882
+ generation,
2883
+ code: diagnostic.code,
2884
+ ...(diagnostic.dimension ? { dimension: diagnostic.dimension } : {}),
2885
+ ...(diagnostic.current ? { current: diagnostic.current } : {}),
2886
+ ...(diagnostic.limit !== undefined ? { limit: diagnostic.limit } : {}),
2887
+ });
2809
2888
  }
2810
2889
  async deliverRunReminder(piboSessionId, expectedGeneration) {
2811
2890
  const scheduled = this.scheduledRunReminders.get(piboSessionId);
@@ -2820,9 +2899,21 @@ export class PiboSessionRouter {
2820
2899
  const session = await this.getOrCreateSession(piboSessionId);
2821
2900
  if (this.closing || this.quiescingSessions.has(piboSessionId) || expectedGeneration !== this.runReminderGeneration(piboSessionId))
2822
2901
  return;
2823
- notification = this.runRegistry.createNotification(piboSessionId, { includeAlreadyNotified: scheduled.includeAlreadyNotified });
2824
- if (!notification)
2902
+ // Release the queued snapshot before reserving its replacement. The
2903
+ // interruption callback restores every unconsumed run to pending state.
2904
+ const replaced = session.removeQueuedMessages?.((event) => {
2905
+ if (!isRunReminderServiceMessage(event) || !event.id)
2906
+ return false;
2907
+ const delivery = this.runReminderDeliveries.get(event.id);
2908
+ return delivery?.piboSessionId === piboSessionId && delivery.generation === expectedGeneration;
2909
+ }) ?? 0;
2910
+ notification = this.runRegistry.createNotification(piboSessionId, {
2911
+ includeAlreadyNotified: scheduled.includeAlreadyNotified || replaced > 0,
2912
+ });
2913
+ if (!notification) {
2914
+ this.clearRunReminderAdmissionWarning(piboSessionId, expectedGeneration);
2825
2915
  return;
2916
+ }
2826
2917
  eventId = randomUUID();
2827
2918
  this.runReminderDeliveries.set(eventId, { piboSessionId, generation: expectedGeneration, notification });
2828
2919
  session.enqueueMessage({
@@ -2833,6 +2924,7 @@ export class PiboSessionRouter {
2833
2924
  id: eventId,
2834
2925
  provenance: runReminderProvenance(notification),
2835
2926
  });
2927
+ this.clearRunReminderAdmissionWarning(piboSessionId, expectedGeneration);
2836
2928
  }
2837
2929
  catch (error) {
2838
2930
  if (eventId)
@@ -2841,13 +2933,9 @@ export class PiboSessionRouter {
2841
2933
  this.runRegistry.releaseNotification(piboSessionId, notification);
2842
2934
  if (this.closing || this.quiescingSessions.has(piboSessionId) || expectedGeneration !== this.runReminderGeneration(piboSessionId))
2843
2935
  return;
2844
- const message = error instanceof Error ? error.message : String(error);
2845
- this.emitOutput({
2846
- type: "session_error",
2847
- piboSessionId,
2848
- error: message,
2849
- errorDetails: runtimeSessionErrorDetails(message),
2850
- });
2936
+ // Reminder delivery is internal recovery work. Preserve it for the next
2937
+ // bounded state transition instead of emitting an unscoped fatal error.
2938
+ this.deferRunReminderAdmission(piboSessionId, expectedGeneration, error);
2851
2939
  }
2852
2940
  }
2853
2941
  }
@@ -69,8 +69,12 @@ export class AsyncChatStorage {
69
69
  ingestUser(input) {
70
70
  return this.writer.request({ type: "ingestUser", input });
71
71
  }
72
- ingestOutput(input) {
73
- return this.writer.request({ type: "ingestOutput", input }, { priority: "output", fairnessKey: input.roomId });
72
+ async ingestOutput(input) {
73
+ const result = await this.writer.request({ type: "ingestOutput", input }, { priority: "output", fairnessKey: input.roomId });
74
+ if (input.event.type === "compaction_end" && result.enrichment?.compactionStats) {
75
+ input.event.compactionStats = result.enrichment.compactionStats;
76
+ }
77
+ return result;
74
78
  }
75
79
  cancelPendingCommands(sessionId) { return this.writer.request({ type: "cancelPendingCommands", sessionId }, { priority: "control" }); }
76
80
  commandReceiptPage(sessionId) { return this.writer.request({ type: "commandReceiptPage", sessionId }, { priority: "control" }); }
@@ -79,6 +83,7 @@ export class AsyncChatStorage {
79
83
  claimCommand(owner, leaseMs) { return this.writer.request({ type: "claimCommand", owner, leaseMs }, { priority: "background" }); }
80
84
  transitionCommand(id, owner, token, state, error) { return this.writer.request({ type: "transitionCommand", id, owner, token, state, error }, { priority: "control" }); }
81
85
  heartbeatCommand(id, owner, token, leaseMs) { return this.writer.request({ type: "heartbeatCommand", id, owner, token, leaseMs }, { priority: "control" }); }
86
+ durableQueueHealth() { return this.writer.request({ type: "durableQueueHealth" }, { priority: "control", timeoutMs: 500 }); }
82
87
  status() {
83
88
  const writer = this.writer.status();
84
89
  const reader = this.reader?.status();
@@ -112,7 +112,7 @@ export class BoundedWorkerClient {
112
112
  pending.settled = true;
113
113
  if (message.error) {
114
114
  this.rejected++;
115
- pending.reject(Object.assign(new Error(message.error.message ?? "Storage operation failed."), { code: message.error.code ?? "storage_operation_failed" }));
115
+ pending.reject(Object.assign(new Error(message.error.message ?? "Storage operation failed."), { code: message.error.code ?? "storage_operation_failed", ...message.error.details }));
116
116
  }
117
117
  else {
118
118
  try {
@@ -12,7 +12,7 @@ END;
12
12
  CREATE TRIGGER IF NOT EXISTS chat_trace_delete AFTER DELETE ON event_log WHEN OLD.session_id IS NOT NULL BEGIN
13
13
  INSERT INTO chat_trace_revisions VALUES(OLD.session_id,1) ON CONFLICT(session_id) DO UPDATE SET revision=revision+1;
14
14
  END;
15
- CREATE INDEX IF NOT EXISTS idx_event_log_unread_stream ON event_log(stream_id) WHERE (retention_class='chat_message' AND type IN ('user.message.accepted','assistant_message')) OR type='session_error';
15
+ CREATE INDEX IF NOT EXISTS idx_event_log_unread_stream ON event_log(stream_id) WHERE type='message_finished';
16
16
  CREATE TABLE IF NOT EXISTS chat_unread_index(stream_id INTEGER PRIMARY KEY,session_id TEXT NOT NULL);
17
17
  CREATE INDEX IF NOT EXISTS idx_chat_unread_session_stream ON chat_unread_index(session_id,stream_id);
18
18
  CREATE TABLE IF NOT EXISTS chat_unread_counts(session_id TEXT PRIMARY KEY,unread_count INTEGER NOT NULL DEFAULT 0);
@@ -23,13 +23,13 @@ END;
23
23
  CREATE TRIGGER IF NOT EXISTS chat_unread_count_delete AFTER DELETE ON chat_unread_index BEGIN
24
24
  UPDATE chat_unread_counts SET unread_count=MAX(0,unread_count-CASE WHEN OLD.stream_id>COALESCE((SELECT last_read_stream_id FROM app_session_read_state WHERE session_id=OLD.session_id),0) THEN 1 ELSE 0 END) WHERE session_id=OLD.session_id;
25
25
  END;
26
- CREATE TRIGGER IF NOT EXISTS chat_unread_event_insert AFTER INSERT ON event_log WHEN NEW.session_id IS NOT NULL AND ((NEW.retention_class='chat_message' AND NEW.type IN ('user.message.accepted','assistant_message')) OR NEW.type='session_error') BEGIN
26
+ CREATE TRIGGER IF NOT EXISTS chat_unread_event_insert AFTER INSERT ON event_log WHEN NEW.session_id IS NOT NULL AND NEW.type='message_finished' BEGIN
27
27
  INSERT OR IGNORE INTO chat_unread_index VALUES(NEW.stream_id,NEW.session_id);
28
28
  END;
29
29
  CREATE TRIGGER IF NOT EXISTS chat_unread_event_delete AFTER DELETE ON event_log BEGIN DELETE FROM chat_unread_index WHERE stream_id=OLD.stream_id; END;
30
30
  CREATE TRIGGER IF NOT EXISTS chat_unread_event_update AFTER UPDATE ON event_log BEGIN
31
31
  DELETE FROM chat_unread_index WHERE stream_id=OLD.stream_id;
32
- INSERT OR IGNORE INTO chat_unread_index SELECT NEW.stream_id,NEW.session_id WHERE NEW.session_id IS NOT NULL AND ((NEW.retention_class='chat_message' AND NEW.type IN ('user.message.accepted','assistant_message')) OR NEW.type='session_error');
32
+ INSERT OR IGNORE INTO chat_unread_index SELECT NEW.stream_id,NEW.session_id WHERE NEW.session_id IS NOT NULL AND NEW.type='message_finished';
33
33
  END;
34
34
  CREATE TRIGGER IF NOT EXISTS chat_unread_mark_insert AFTER INSERT ON app_session_read_state BEGIN
35
35
  INSERT INTO chat_unread_counts(session_id,unread_count) SELECT NEW.session_id,COUNT(*) FROM chat_unread_index WHERE session_id=NEW.session_id AND stream_id>NEW.last_read_stream_id
@@ -103,7 +103,7 @@ export class ChatReadProjectionStore {
103
103
  return { ...current, processed: 0 };
104
104
  }
105
105
  if (current.historyComplete) {
106
- const rows = this.db.prepare("SELECT stream_id,session_id FROM event_log INDEXED BY idx_event_log_unread_stream WHERE stream_id>? AND stream_id<=? AND ((retention_class='chat_message' AND type IN ('user.message.accepted','assistant_message')) OR type='session_error') ORDER BY stream_id LIMIT ?").all(current.event_cursor, current.event_target, limit);
106
+ const rows = this.db.prepare("SELECT stream_id,session_id FROM event_log INDEXED BY idx_event_log_unread_stream WHERE stream_id>? AND stream_id<=? AND type='message_finished' ORDER BY stream_id LIMIT ?").all(current.event_cursor, current.event_target, limit);
107
107
  const insert = this.db.prepare("INSERT OR IGNORE INTO chat_unread_index VALUES(?,?)");
108
108
  let processed = 0;
109
109
  const started = performance.now();
@@ -19,6 +19,15 @@ const ingest = new ChatDataIngestService(store);
19
19
  const rooms = new ChatRoomService(store);
20
20
  const sessions = new ChatSessionQueryService(store);
21
21
  const messageCommands = new MessageCommandStore(store);
22
+ // Startup repair is bounded and conservative: terminal evidence may settle a receipt,
23
+ // but ambiguous work is retained and never replayed. A damaged row must not stop the worker.
24
+ let startupReconciliation;
25
+ try {
26
+ startupReconciliation = messageCommands.reconcileInterrupted();
27
+ }
28
+ catch (error) {
29
+ startupReconciliation = { error: error instanceof Error ? error.message.slice(0, 500) : "Unknown reconciliation error" };
30
+ }
22
31
  let active = false;
23
32
  let operations = 0;
24
33
  let busyRetries = 0;
@@ -32,6 +41,7 @@ function execute(command) {
32
41
  case "claimCommand": return messageCommands.claim(command.owner, command.leaseMs);
33
42
  case "transitionCommand": return messageCommands.transition(command.id, command.owner, command.token, command.state, command.error);
34
43
  case "heartbeatCommand": return messageCommands.heartbeat(command.id, command.owner, command.token, command.leaseMs);
44
+ case "durableQueueHealth": return messageCommands.health();
35
45
  case "resolveRoom": {
36
46
  const room = command.roomId ? rooms.getRoom(command.roomId) : undefined;
37
47
  if (room)
@@ -54,6 +64,8 @@ function execute(command) {
54
64
  throw Object.assign(new Error("Transaction belongs to the legacy admission contract."), { code: "command_conflict" });
55
65
  if (existing)
56
66
  return { event: commands.findByClientTxn(room.id, command.input.actorId, command.input.clientTxnId), created: false, receipt };
67
+ if (command.durableCommand)
68
+ messageCommands.assertAdmissionUnblocked(command.session.id, command.durableCommand.delivery);
57
69
  const preparedCommand = commandInput ? messageCommands.prepare(commandInput) : undefined;
58
70
  const createdAt = command.input.createdAt ?? new Date().toISOString();
59
71
  const preparedPayload = ingest.prepareUserMessagePayload(command.text, createdAt);
@@ -65,6 +77,8 @@ function execute(command) {
65
77
  throw Object.assign(new Error("Transaction belongs to the legacy admission contract."), { code: "command_conflict" });
66
78
  return { event: commands.findByClientTxn(room.id, command.input.actorId, command.input.clientTxnId), created: false, receipt };
67
79
  }
80
+ if (command.durableCommand)
81
+ messageCommands.assertAdmissionUnblocked(command.session.id, command.durableCommand.delivery);
68
82
  const event = commands.appendEvent({ ...command.input, createdAt });
69
83
  sessions.upsertSession(command.session, command.durableCommand ? sessions.getSession(command.session.id)?.status ?? "idle" : "idle", command.session.updatedAt, { preserveRuntimeBinding: true });
70
84
  ingest.ingestUserMessageAccepted({ session: command.session, roomId: room.id, actorId: command.input.actorId ?? "", text: command.text, clientTxnId: command.input.clientTxnId, eventId: command.durableCommand?.eventId, legacyEvent: event, preparedPayload });
@@ -85,10 +99,14 @@ function execute(command) {
85
99
  case "ingestOutput": {
86
100
  const result = ingest.ingestOutputEvent(command.input);
87
101
  messageCommands.recordOutput(command.input.session.id, "eventId" in command.input.event ? command.input.event.eventId : undefined, command.input.event.type);
88
- const row = store.db.prepare("SELECT created_at, event_id FROM event_log WHERE stream_id = ?").get(result.streamId);
102
+ const row = store.db.prepare("SELECT created_at, event_id, attributes_json FROM event_log WHERE stream_id = ?").get(result.streamId);
89
103
  if (!row)
90
104
  throw new Error(`Missing output event ${result.streamId} after ingest.`);
91
- return { ...result, stored: { createdAt: row.created_at, eventId: row.event_id ?? String(result.streamId) } };
105
+ const attributes = JSON.parse(row.attributes_json);
106
+ const enrichment = command.input.event.type === "compaction_end" && attributes.compactionStats
107
+ ? { compactionStats: attributes.compactionStats }
108
+ : undefined;
109
+ return { ...result, stored: { createdAt: row.created_at, eventId: row.event_id ?? String(result.streamId) }, enrichment };
92
110
  }
93
111
  case "status": return { operations, busyRetries, lastOperationMs, pid: process.pid, synchronous: store.db.prepare("PRAGMA synchronous").get(), journalMode: store.db.prepare("PRAGMA journal_mode").get() };
94
112
  }
@@ -122,7 +140,9 @@ function attempt(request) {
122
140
  }
123
141
  const domainCode = error && typeof error === "object" && "code" in error ? String(error.code) : "";
124
142
  if (domainCode === "room_not_found" || domainCode === "room_read_only" || (domainCode.startsWith("storage_") || domainCode.startsWith("command_")) || domainCode === "pibo_output_identity_collision") {
125
- respond(request, { error: { code: domainCode, message } });
143
+ const source = error;
144
+ const details = Object.fromEntries(["retryable", "scope", "blockingCommandId", "blockedSince", "oldestWaitAgeMs", "nextAction"].flatMap(key => source[key] === undefined ? [] : [[key, source[key]]]));
145
+ respond(request, { error: { code: domainCode, message, ...(Object.keys(details).length ? { details } : {}) } });
126
146
  return;
127
147
  }
128
148
  respond(request, { error: { code: /database is (?:locked|busy)/i.test(message) ? "storage_busy" : "storage_operation_failed", message: "Storage operation failed; reconcile the transaction ID before retrying." } });
@@ -142,5 +162,6 @@ port.postMessage({
142
162
  ...workerStatus(),
143
163
  journalMode: store.db.prepare("PRAGMA journal_mode").get(),
144
164
  synchronous: store.db.prepare("PRAGMA synchronous").get(),
165
+ startupReconciliation,
145
166
  },
146
167
  });
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { outputIdentityFingerprint, outputPartFingerprint } from "../core/output-render-sequence.js";
2
+ import { legacyOutputIdentityFingerprintCandidates, OUTPUT_IDENTITY_FINGERPRINT_VERSION, outputIdentityFieldDigests, outputIdentityFingerprint, outputPartFingerprint } from "../core/output-render-sequence.js";
3
3
  import { rootSessionId } from "./session-store.js";
4
4
  export class PiboOutputIdentityCollisionError extends Error {
5
5
  idempotencyKey;
@@ -26,6 +26,7 @@ export function outputPersistenceErrorIsRetryable(error) {
26
26
  }
27
27
  const INLINE_MESSAGE_PAYLOAD_THRESHOLD_BYTES = 16 * 1024;
28
28
  const INLINE_JSON_PAYLOAD_THRESHOLD_BYTES = 16 * 1024;
29
+ const MAX_FINGERPRINT_FIELD_DIFFERENCES = 24;
29
30
  export class ChatDataIngestService {
30
31
  store;
31
32
  constructor(store) {
@@ -106,15 +107,30 @@ export class ChatDataIngestService {
106
107
  const event = input.event;
107
108
  const idempotencyKey = outputIdempotencyKey(event);
108
109
  const identityFingerprint = outputIdentityFingerprint(event);
110
+ const identityFieldDigests = outputIdentityFieldDigests(event);
111
+ const incomingProvenance = input.persistenceProvenance ?? {
112
+ producer: "direct-ingest",
113
+ projection: "product-history",
114
+ phase: "direct",
115
+ };
109
116
  const partFingerprint = isOutputPartEvent(event) ? outputPartFingerprint(event) : undefined;
110
117
  if (idempotencyKey) {
111
- const existing = this.store.eventLog.findByIdempotencyKey(idempotencyKey);
118
+ const directExisting = this.store.eventLog.findByIdempotencyKey(idempotencyKey);
119
+ const legacyExisting = !directExisting && event.type === "execution_result"
120
+ ? this.store.eventLog.findByIdempotencyKey(legacyOutputIdempotencyKey(event))
121
+ : undefined;
122
+ const legacyPhaseMatches = legacyExisting && event.type === "execution_result"
123
+ ? legacyExecutionResultPhase(legacyExisting.attributes) === executionResultPhase(event)
124
+ : false;
125
+ const existing = directExisting ?? (legacyExisting && (storedFingerprintMatches(legacyExisting.attributes, event, identityFingerprint) || legacyPhaseMatches) ? legacyExisting : undefined);
112
126
  if (existing) {
113
127
  const existingFingerprint = typeof existing.attributes.identityFingerprint === "string"
114
128
  ? existing.attributes.identityFingerprint
115
129
  : undefined;
116
- if (existingFingerprint && existingFingerprint !== identityFingerprint) {
130
+ if (existingFingerprint && !storedFingerprintMatches(existing.attributes, event, identityFingerprint)) {
117
131
  const now = input.createdAt ?? new Date().toISOString();
132
+ const existingFieldDigests = stringMap(existing.attributes.identityFieldDigests);
133
+ const fieldDifferences = diffFieldDigests(existingFieldDigests, identityFieldDigests);
118
134
  this.store.eventLog.appendEvent({
119
135
  sessionId: input.session.id,
120
136
  sessionSequence: this.nextEventSequence(input.session.id),
@@ -133,6 +149,10 @@ export class ChatDataIngestService {
133
149
  existingFingerprint,
134
150
  incomingFingerprint: identityFingerprint,
135
151
  incomingType: event.type,
152
+ existingProvenance: redactedPersistenceProvenance(existing.attributes.persistenceProvenance),
153
+ incomingProvenance,
154
+ fieldDifferences,
155
+ fieldDifferencesTruncated: fieldDifferences.length >= MAX_FINGERPRINT_FIELD_DIFFERENCES,
136
156
  },
137
157
  createdAt: now,
138
158
  indexedAt: now,
@@ -177,6 +197,9 @@ export class ChatDataIngestService {
177
197
  previewText: previewTextForOutputEvent(event),
178
198
  attributes: compactObject({
179
199
  identityFingerprint,
200
+ identityFingerprintVersion: OUTPUT_IDENTITY_FINGERPRINT_VERSION,
201
+ identityFieldDigests,
202
+ persistenceProvenance: incomingProvenance,
180
203
  outputPartFingerprint: partFingerprint,
181
204
  eventIdentityScoped: eventIdForOutputEvent(event) !== undefined,
182
205
  semanticEventId: eventIdForOutputEvent(event),
@@ -340,6 +363,43 @@ function nonNegativeFiniteNumber(value) {
340
363
  function isRecord(value) {
341
364
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
342
365
  }
366
+ function stringMap(value) {
367
+ if (!isRecord(value))
368
+ return {};
369
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string").slice(0, 64));
370
+ }
371
+ function diffFieldDigests(existing, incoming) {
372
+ return [...new Set([...Object.keys(existing), ...Object.keys(incoming)])]
373
+ .sort()
374
+ .filter((field) => existing[field] !== incoming[field])
375
+ .slice(0, MAX_FINGERPRINT_FIELD_DIFFERENCES)
376
+ .map((field) => ({
377
+ field,
378
+ change: existing[field] === undefined ? "added" : incoming[field] === undefined ? "removed" : "changed",
379
+ }));
380
+ }
381
+ function redactedPersistenceProvenance(value) {
382
+ if (!isRecord(value))
383
+ return { producer: "unknown", projection: "product-history", phase: "unknown" };
384
+ const bounded = (field) => typeof value[field] === "string" ? value[field].slice(0, 64) : "unknown";
385
+ return { producer: bounded("producer"), projection: bounded("projection"), phase: bounded("phase") };
386
+ }
387
+ function executionResultPhase(event) {
388
+ return isRecord(event.result) && event.result.queued === true ? "queued" : "complete";
389
+ }
390
+ function legacyExecutionResultPhase(attributes) {
391
+ return isRecord(attributes.inlinePayload) && attributes.inlinePayload.queued === true ? "queued" : "complete";
392
+ }
393
+ function storedFingerprintMatches(attributes, event, currentFingerprint) {
394
+ const fingerprint = attributes.identityFingerprint;
395
+ if (typeof fingerprint !== "string")
396
+ return true;
397
+ if (attributes.identityFingerprintVersion === OUTPUT_IDENTITY_FINGERPRINT_VERSION)
398
+ return fingerprint === currentFingerprint;
399
+ // Versionless fingerprints were produced by v1. Compare with the exact old
400
+ // algorithm instead of comparing incompatible hash formats.
401
+ return legacyOutputIdentityFingerprintCandidates(event).includes(fingerprint);
402
+ }
343
403
  function deterministicId(prefix, value) {
344
404
  return `${prefix}_${createHash("sha256").update(value).digest("hex").slice(0, 32)}`;
345
405
  }
@@ -378,6 +438,13 @@ export function outputPersistenceDeliveryKey(event) {
378
438
  return outputIdempotencyKey(event)
379
439
  ?? `pibo.output:${event.piboSessionId}:${event.type}:render:${event.renderSequence ?? "unpositioned"}`;
380
440
  }
441
+ /** Accepted only while recovering pre-phase execution-result envelopes. */
442
+ export function legacyOutputIdempotencyKey(event) {
443
+ if (event.type !== "execution_result")
444
+ return outputIdempotencyKey(event);
445
+ const base = event.eventId;
446
+ return base ? `pibo.output:${event.piboSessionId}:${event.type}:${base}:${event.action}` : undefined;
447
+ }
381
448
  function outputPartKey(event) {
382
449
  if (event.type === "tool_call")
383
450
  return `${event.toolCallId}:${event.toolInvocationOrdinal ?? 0}:${event.argsComplete ? "complete" : "partial"}:${hashJson(event.args)}`;
@@ -394,7 +461,7 @@ function outputPartKey(event) {
394
461
  if (event.type === "compaction_start" || event.type === "compaction_end")
395
462
  return String(event.compactionIndex ?? 0);
396
463
  if (event.type === "execution_result")
397
- return event.action;
464
+ return `${event.action}:${executionResultPhase(event)}`;
398
465
  return "main";
399
466
  }
400
467
  function messageIdForOutputEvent(event) {
@@ -479,6 +546,8 @@ function specificAttributesForOutputEvent(event) {
479
546
  if (event.type === "assistant_usage")
480
547
  return {
481
548
  usageIndex: event.usageIndex,
549
+ inferenceId: event.inferenceId,
550
+ inferenceTarget: event.inferenceTarget,
482
551
  inputTokens: event.inputTokens,
483
552
  outputTokens: event.outputTokens,
484
553
  cacheReadTokens: event.cacheReadTokens,