@pasko70/pibo 3.4.4 → 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.
- package/dist/agent-runtime/routed-session.js +111 -25
- package/dist/agent-runtimes/codex-native/turn.js +27 -3
- package/dist/agent-runtimes/pi/adapter.js +1 -0
- package/dist/agent-runtimes/pi/routed-session.js +2 -2
- package/dist/apps/chat/bounded-event-stream.js +98 -0
- package/dist/apps/chat/chat-settings-routes.js +3 -3
- package/dist/apps/chat/data/chat-data-mappers.js +40 -14
- package/dist/apps/chat/data/event-command-service.js +30 -21
- package/dist/apps/chat/data/history-query-service.js +34 -25
- package/dist/apps/chat/data/read-state-service.js +37 -3
- package/dist/apps/chat/data/session-query-service.js +19 -11
- package/dist/apps/chat/data/timeline-query-service.js +19 -7
- package/dist/apps/chat/message-command-dispatcher.js +149 -0
- package/dist/apps/chat/output-compactor.js +9 -0
- package/dist/apps/chat/output-event-policy.js +9 -1
- package/dist/apps/chat/stream.js +21 -3
- package/dist/apps/chat/telemetry-retention-service.js +113 -8
- package/dist/apps/chat/trace-response-cache.js +46 -0
- package/dist/apps/chat/trace-v2.js +12 -6
- package/dist/apps/chat/trace.js +1 -1
- package/dist/apps/chat/web-app.js +645 -297
- package/dist/apps/chat-ui/assets/{dist-V06sfuZa.js → dist-B-auLrzD.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CH3SvpYV.js → dist-BA_dsINH.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-23lt_7qm.js → dist-eJZar_0-.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-Bf2KScPo.js → dist-wNNR2Bci.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DsgL8w-W.js → dist-zcmEsIEp.js} +1 -1
- package/dist/apps/chat-ui/assets/index-DEkbN5Vo.js +229 -0
- package/dist/apps/chat-ui/assets/index-DZK6Tzil.css +1 -0
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/index-DZgW1fCB.js +44 -0
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/cli-session/localSessionSource.js +3 -2
- package/dist/compute/pool/seeds.js +25 -3
- package/dist/core/events.js +4 -0
- package/dist/core/output-render-sequence.js +65 -6
- package/dist/core/provider-capacity.js +33 -0
- package/dist/core/provider-telemetry.js +21 -6
- package/dist/core/runtime-capacity.js +174 -0
- package/dist/core/runtime-telemetry.js +40 -12
- package/dist/core/session-router.js +178 -13
- package/dist/data/async-chat-reads.js +38 -0
- package/dist/data/async-chat-storage.js +96 -0
- package/dist/data/async-telemetry-maintenance.js +9 -0
- package/dist/data/bounded-worker-client.js +256 -0
- package/dist/data/chat-read-projections.js +159 -0
- package/dist/data/chat-read-worker.js +73 -0
- package/dist/data/chat-storage-worker.js +167 -0
- package/dist/data/ingest-service.js +152 -18
- package/dist/data/message-command-store.js +290 -0
- package/dist/data/payload-store.js +92 -11
- package/dist/data/pibo-store.js +10 -8
- package/dist/data/schema.js +39 -4
- package/dist/data/session-store.js +3 -1
- package/dist/data/storage-backup.js +278 -0
- package/dist/data/storage-maintenance.js +344 -0
- package/dist/data/storage-verification-worker.js +25 -0
- package/dist/data/telemetry-capture.js +188 -0
- package/dist/data/telemetry-command.js +3 -0
- package/dist/data/telemetry-maintenance-worker.js +40 -0
- package/dist/data/telemetry-maintenance.js +110 -0
- package/dist/data/telemetry-retention.js +16 -7
- package/dist/data/telemetry-worker.js +111 -0
- package/dist/data/telemetry-writer.js +150 -83
- package/dist/data/telemetry.js +5 -0
- package/dist/debug/index.js +207 -1
- package/dist/debug/message-queue.js +108 -0
- package/dist/debug/output-collision-repair.js +140 -0
- package/dist/debug/output-integrity.js +38 -2
- package/dist/debug/output-repair.js +1 -0
- package/dist/debug/storage-backup.js +41 -0
- package/dist/debug/storage-maintenance.js +78 -0
- package/dist/debug/telemetry-capture.js +66 -0
- package/dist/gateway/cli.js +71 -7
- package/dist/gateway/server.js +3 -0
- package/dist/providers/openai-gpt56.js +11 -6
- package/dist/reliability/store.js +119 -23
- package/dist/session-ui/terminalRows.js +54 -13
- package/dist/sessions/pibo-data-store.js +19 -14
- package/dist/shared/debug-features.js +4 -0
- package/dist/shared/model-inference-metrics.js +23 -0
- package/dist/shared/trace-event-projection.js +69 -2
- package/dist/shared/trace-history.js +9 -0
- package/dist/shared/trace-live-reducer.js +1 -0
- package/dist/shared/trace-patch-nodes.js +19 -0
- package/dist/web/channel.js +117 -12
- package/dist/web/http.js +135 -41
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-BOceJ0jM.css +0 -1
- package/dist/apps/chat-ui/assets/index-BOemYq-V.js +0 -228
- package/dist/apps/chat-vscode-web/assets/index-CMwTB8o8.js +0 -43
|
@@ -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;
|
|
@@ -17,6 +17,8 @@ export class PiboOutputIdentityCollisionError extends Error {
|
|
|
17
17
|
export function outputPersistenceErrorIsRetryable(error) {
|
|
18
18
|
if (error instanceof PiboOutputIdentityCollisionError)
|
|
19
19
|
return false;
|
|
20
|
+
if (error && typeof error === "object" && "code" in error && error.code === "pibo_output_identity_collision")
|
|
21
|
+
return false;
|
|
20
22
|
if (error instanceof AggregateError) {
|
|
21
23
|
return error.errors.length === 0 || error.errors.some(outputPersistenceErrorIsRetryable);
|
|
22
24
|
}
|
|
@@ -24,6 +26,7 @@ export function outputPersistenceErrorIsRetryable(error) {
|
|
|
24
26
|
}
|
|
25
27
|
const INLINE_MESSAGE_PAYLOAD_THRESHOLD_BYTES = 16 * 1024;
|
|
26
28
|
const INLINE_JSON_PAYLOAD_THRESHOLD_BYTES = 16 * 1024;
|
|
29
|
+
const MAX_FINGERPRINT_FIELD_DIFFERENCES = 24;
|
|
27
30
|
export class ChatDataIngestService {
|
|
28
31
|
store;
|
|
29
32
|
constructor(store) {
|
|
@@ -43,10 +46,11 @@ export class ChatDataIngestService {
|
|
|
43
46
|
duplicate: true,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
49
|
+
const now = input.legacyEvent?.createdAt ?? new Date().toISOString();
|
|
50
|
+
const preparedPayload = input.preparedPayload ?? this.prepareUserMessagePayload(input.text, now);
|
|
46
51
|
return this.store.transaction(() => {
|
|
47
|
-
|
|
48
|
-
this.store.
|
|
49
|
-
const payloadRef = this.writeTextPayloadIfLarge(input.text, now, "chat_message");
|
|
52
|
+
this.store.sessions.upsertSession({ session: input.session, roomId: input.roomId, firstMessagePreview: input.text, lastActivityAt: now, preserveRuntimeBinding: true });
|
|
53
|
+
const payloadRef = preparedPayload ? this.store.payloads.commitPreparedPayload(preparedPayload).id : undefined;
|
|
50
54
|
const event = this.store.eventLog.appendEvent({
|
|
51
55
|
sessionId: input.session.id,
|
|
52
56
|
sessionSequence: this.nextEventSequence(input.session.id),
|
|
@@ -79,6 +83,7 @@ export class ChatDataIngestService {
|
|
|
79
83
|
roomId: input.roomId,
|
|
80
84
|
sequence: this.nextMessageSequence(input.session.id),
|
|
81
85
|
role: "user",
|
|
86
|
+
turnId: input.eventId,
|
|
82
87
|
actorId: input.actorId,
|
|
83
88
|
status: "complete",
|
|
84
89
|
createdAt: now,
|
|
@@ -91,23 +96,41 @@ export class ChatDataIngestService {
|
|
|
91
96
|
inlineText: payloadRef ? undefined : input.text,
|
|
92
97
|
}),
|
|
93
98
|
});
|
|
94
|
-
this.upsertNavigation(input.session, input.roomId, previewText(input.text), now, "running");
|
|
99
|
+
this.upsertNavigation(input.session, input.roomId, previewText(input.text), now, input.eventId ? undefined : "running");
|
|
95
100
|
return { streamId: event.streamId, messageId, duplicate: false };
|
|
96
101
|
});
|
|
97
102
|
}
|
|
103
|
+
prepareUserMessagePayload(text, createdAt) {
|
|
104
|
+
return this.prepareTextPayloadIfLarge(text, createdAt, "chat_message");
|
|
105
|
+
}
|
|
98
106
|
ingestOutputEvent(input) {
|
|
99
107
|
const event = input.event;
|
|
100
108
|
const idempotencyKey = outputIdempotencyKey(event);
|
|
101
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
|
+
};
|
|
102
116
|
const partFingerprint = isOutputPartEvent(event) ? outputPartFingerprint(event) : undefined;
|
|
103
117
|
if (idempotencyKey) {
|
|
104
|
-
const
|
|
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);
|
|
105
126
|
if (existing) {
|
|
106
127
|
const existingFingerprint = typeof existing.attributes.identityFingerprint === "string"
|
|
107
128
|
? existing.attributes.identityFingerprint
|
|
108
129
|
: undefined;
|
|
109
|
-
if (existingFingerprint &&
|
|
130
|
+
if (existingFingerprint && !storedFingerprintMatches(existing.attributes, event, identityFingerprint)) {
|
|
110
131
|
const now = input.createdAt ?? new Date().toISOString();
|
|
132
|
+
const existingFieldDigests = stringMap(existing.attributes.identityFieldDigests);
|
|
133
|
+
const fieldDifferences = diffFieldDigests(existingFieldDigests, identityFieldDigests);
|
|
111
134
|
this.store.eventLog.appendEvent({
|
|
112
135
|
sessionId: input.session.id,
|
|
113
136
|
sessionSequence: this.nextEventSequence(input.session.id),
|
|
@@ -126,6 +149,10 @@ export class ChatDataIngestService {
|
|
|
126
149
|
existingFingerprint,
|
|
127
150
|
incomingFingerprint: identityFingerprint,
|
|
128
151
|
incomingType: event.type,
|
|
152
|
+
existingProvenance: redactedPersistenceProvenance(existing.attributes.persistenceProvenance),
|
|
153
|
+
incomingProvenance,
|
|
154
|
+
fieldDifferences,
|
|
155
|
+
fieldDifferencesTruncated: fieldDifferences.length >= MAX_FINGERPRINT_FIELD_DIFFERENCES,
|
|
129
156
|
},
|
|
130
157
|
createdAt: now,
|
|
131
158
|
indexedAt: now,
|
|
@@ -140,13 +167,17 @@ export class ChatDataIngestService {
|
|
|
140
167
|
};
|
|
141
168
|
}
|
|
142
169
|
}
|
|
170
|
+
const now = input.createdAt ?? new Date().toISOString();
|
|
171
|
+
const payload = payloadForOutputEvent(event);
|
|
172
|
+
const preparedPayload = payload ? this.preparePayloadIfLarge(payload.value, payload.contentType, now, retentionClassForOutputEvent(event)) : undefined;
|
|
143
173
|
return this.store.transaction(() => {
|
|
144
|
-
|
|
174
|
+
if (event.type === "compaction_end" && !event.aborted && !event.errorMessage && !event.compactionStats) {
|
|
175
|
+
event.compactionStats = this.compactionStats(input.session.id, event.result);
|
|
176
|
+
}
|
|
145
177
|
if (input.roomId) {
|
|
146
|
-
this.store.sessions.upsertSession({ session: input.session, roomId: input.roomId, lastActivityAt: now, status: outputSessionStatus(event) });
|
|
178
|
+
this.store.sessions.upsertSession({ session: input.session, roomId: input.roomId, lastActivityAt: now, status: outputSessionStatus(event), preserveRuntimeBinding: true });
|
|
147
179
|
}
|
|
148
|
-
const
|
|
149
|
-
const payloadRef = payload ? this.writePayloadIfLarge(payload.value, payload.contentType, now, retentionClassForOutputEvent(event)) : undefined;
|
|
180
|
+
const payloadRef = preparedPayload ? this.store.payloads.commitPreparedPayload(preparedPayload).id : undefined;
|
|
150
181
|
const storedEvent = this.store.eventLog.appendEvent({
|
|
151
182
|
sessionId: input.session.id,
|
|
152
183
|
sessionSequence: this.nextEventSequence(input.session.id),
|
|
@@ -166,6 +197,9 @@ export class ChatDataIngestService {
|
|
|
166
197
|
previewText: previewTextForOutputEvent(event),
|
|
167
198
|
attributes: compactObject({
|
|
168
199
|
identityFingerprint,
|
|
200
|
+
identityFingerprintVersion: OUTPUT_IDENTITY_FINGERPRINT_VERSION,
|
|
201
|
+
identityFieldDigests,
|
|
202
|
+
persistenceProvenance: incomingProvenance,
|
|
169
203
|
outputPartFingerprint: partFingerprint,
|
|
170
204
|
eventIdentityScoped: eventIdForOutputEvent(event) !== undefined,
|
|
171
205
|
semanticEventId: eventIdForOutputEvent(event),
|
|
@@ -233,6 +267,34 @@ export class ChatDataIngestService {
|
|
|
233
267
|
return { streamId: storedEvent.streamId, duplicate: false, messageId, observationId };
|
|
234
268
|
});
|
|
235
269
|
}
|
|
270
|
+
compactionStats(sessionId, result) {
|
|
271
|
+
const boundary = this.store.db.prepare(`
|
|
272
|
+
SELECT COALESCE(MAX(session_sequence), 0) AS sequence
|
|
273
|
+
FROM event_log
|
|
274
|
+
WHERE session_id = ?
|
|
275
|
+
AND type = 'compaction_end'
|
|
276
|
+
AND COALESCE(json_extract(attributes_json, '$.aborted'), 0) = 0
|
|
277
|
+
AND json_extract(attributes_json, '$.errorMessage') IS NULL
|
|
278
|
+
`).get(sessionId);
|
|
279
|
+
const segmentWhere = `session_id = ? AND type = 'tool_execution_finished' AND session_sequence > ?`;
|
|
280
|
+
const count = this.store.db.prepare(`SELECT COUNT(*) AS count FROM event_log WHERE ${segmentWhere}`)
|
|
281
|
+
.get(sessionId, boundary.sequence);
|
|
282
|
+
const maxRow = this.store.db.prepare(`
|
|
283
|
+
SELECT attributes_json
|
|
284
|
+
FROM event_log
|
|
285
|
+
WHERE ${segmentWhere}
|
|
286
|
+
AND json_type(attributes_json, '$.toolMetrics.outputTokens') IN ('integer', 'real')
|
|
287
|
+
ORDER BY json_extract(attributes_json, '$.toolMetrics.outputTokens') DESC
|
|
288
|
+
LIMIT 1
|
|
289
|
+
`).get(sessionId, boundary.sequence);
|
|
290
|
+
const maxToolOutput = maxRow ? toolMetricsFromAttributes(maxRow.attributes_json) : undefined;
|
|
291
|
+
return compactObject({
|
|
292
|
+
toolCallCount: count.count,
|
|
293
|
+
maxToolOutputTokens: maxToolOutput?.outputTokens,
|
|
294
|
+
maxToolOutputTokenBasis: maxToolOutput?.tokenBasis,
|
|
295
|
+
compactionTokens: compactionTokenCount(result),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
236
298
|
nextEventSequence(sessionId) {
|
|
237
299
|
const row = this.store.db.prepare("SELECT COALESCE(MAX(session_sequence), 0) + 1 AS next_sequence FROM event_log WHERE session_id = ?").get(sessionId);
|
|
238
300
|
return row.next_sequence;
|
|
@@ -241,22 +303,22 @@ export class ChatDataIngestService {
|
|
|
241
303
|
const row = this.store.db.prepare("SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
242
304
|
return row.next_sequence;
|
|
243
305
|
}
|
|
244
|
-
|
|
306
|
+
prepareTextPayloadIfLarge(text, createdAt, retentionClass) {
|
|
245
307
|
if (Buffer.byteLength(text, "utf8") <= INLINE_MESSAGE_PAYLOAD_THRESHOLD_BYTES)
|
|
246
308
|
return undefined;
|
|
247
|
-
return this.store.payloads.
|
|
309
|
+
return this.store.payloads.preparePayload({
|
|
248
310
|
value: text,
|
|
249
311
|
contentType: "text/plain; charset=utf-8",
|
|
250
312
|
retentionClass,
|
|
251
313
|
createdAt,
|
|
252
|
-
})
|
|
314
|
+
});
|
|
253
315
|
}
|
|
254
|
-
|
|
316
|
+
preparePayloadIfLarge(value, contentType, createdAt, retentionClass) {
|
|
255
317
|
const bytes = Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value), "utf8");
|
|
256
318
|
const threshold = typeof value === "string" ? INLINE_MESSAGE_PAYLOAD_THRESHOLD_BYTES : INLINE_JSON_PAYLOAD_THRESHOLD_BYTES;
|
|
257
319
|
if (bytes <= threshold)
|
|
258
320
|
return undefined;
|
|
259
|
-
return this.store.payloads.
|
|
321
|
+
return this.store.payloads.preparePayload({ value, contentType, retentionClass, createdAt });
|
|
260
322
|
}
|
|
261
323
|
upsertNavigation(session, roomId, lastMessagePreview, now, status) {
|
|
262
324
|
this.store.navigation.upsertSession({
|
|
@@ -275,6 +337,69 @@ export class ChatDataIngestService {
|
|
|
275
337
|
});
|
|
276
338
|
}
|
|
277
339
|
}
|
|
340
|
+
function toolMetricsFromAttributes(attributesJson) {
|
|
341
|
+
try {
|
|
342
|
+
const attributes = JSON.parse(attributesJson);
|
|
343
|
+
if (!isRecord(attributes) || !isRecord(attributes.toolMetrics))
|
|
344
|
+
return undefined;
|
|
345
|
+
const outputTokens = nonNegativeFiniteNumber(attributes.toolMetrics.outputTokens);
|
|
346
|
+
const tokenBasis = typeof attributes.toolMetrics.tokenBasis === "string"
|
|
347
|
+
? attributes.toolMetrics.tokenBasis
|
|
348
|
+
: undefined;
|
|
349
|
+
return { outputTokens, tokenBasis };
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function compactionTokenCount(result) {
|
|
356
|
+
if (!isRecord(result))
|
|
357
|
+
return undefined;
|
|
358
|
+
return nonNegativeFiniteNumber(result.tokensBefore);
|
|
359
|
+
}
|
|
360
|
+
function nonNegativeFiniteNumber(value) {
|
|
361
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
362
|
+
}
|
|
363
|
+
function isRecord(value) {
|
|
364
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
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
|
+
}
|
|
278
403
|
function deterministicId(prefix, value) {
|
|
279
404
|
return `${prefix}_${createHash("sha256").update(value).digest("hex").slice(0, 32)}`;
|
|
280
405
|
}
|
|
@@ -313,6 +438,13 @@ export function outputPersistenceDeliveryKey(event) {
|
|
|
313
438
|
return outputIdempotencyKey(event)
|
|
314
439
|
?? `pibo.output:${event.piboSessionId}:${event.type}:render:${event.renderSequence ?? "unpositioned"}`;
|
|
315
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
|
+
}
|
|
316
448
|
function outputPartKey(event) {
|
|
317
449
|
if (event.type === "tool_call")
|
|
318
450
|
return `${event.toolCallId}:${event.toolInvocationOrdinal ?? 0}:${event.argsComplete ? "complete" : "partial"}:${hashJson(event.args)}`;
|
|
@@ -329,7 +461,7 @@ function outputPartKey(event) {
|
|
|
329
461
|
if (event.type === "compaction_start" || event.type === "compaction_end")
|
|
330
462
|
return String(event.compactionIndex ?? 0);
|
|
331
463
|
if (event.type === "execution_result")
|
|
332
|
-
return event.action
|
|
464
|
+
return `${event.action}:${executionResultPhase(event)}`;
|
|
333
465
|
return "main";
|
|
334
466
|
}
|
|
335
467
|
function messageIdForOutputEvent(event) {
|
|
@@ -414,6 +546,8 @@ function specificAttributesForOutputEvent(event) {
|
|
|
414
546
|
if (event.type === "assistant_usage")
|
|
415
547
|
return {
|
|
416
548
|
usageIndex: event.usageIndex,
|
|
549
|
+
inferenceId: event.inferenceId,
|
|
550
|
+
inferenceTarget: event.inferenceTarget,
|
|
417
551
|
inputTokens: event.inputTokens,
|
|
418
552
|
outputTokens: event.outputTokens,
|
|
419
553
|
cacheReadTokens: event.cacheReadTokens,
|
|
@@ -435,7 +569,7 @@ function specificAttributesForOutputEvent(event) {
|
|
|
435
569
|
if (event.type === "session_error")
|
|
436
570
|
return { error: event.error, ...(event.errorDetails ? { errorDetails: event.errorDetails } : {}) };
|
|
437
571
|
if (event.type === "compaction_start" || event.type === "compaction_end")
|
|
438
|
-
return { compactionIndex: event.compactionIndex, reason: event.reason, aborted: "aborted" in event ? event.aborted : undefined, errorMessage: "errorMessage" in event ? event.errorMessage : undefined };
|
|
572
|
+
return { compactionIndex: event.compactionIndex, reason: event.reason, aborted: "aborted" in event ? event.aborted : undefined, errorMessage: "errorMessage" in event ? event.errorMessage : undefined, compactionStats: "compactionStats" in event ? event.compactionStats : undefined };
|
|
439
573
|
return {};
|
|
440
574
|
}
|
|
441
575
|
function observationKindForOutputEvent(event) {
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
export const MESSAGE_COMMAND_SCHEMA = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS message_commands (
|
|
4
|
+
id TEXT PRIMARY KEY, request_key TEXT NOT NULL UNIQUE, fingerprint TEXT NOT NULL,
|
|
5
|
+
session_id TEXT NOT NULL, room_id TEXT NOT NULL, event_id TEXT NOT NULL,
|
|
6
|
+
stream_id INTEGER NOT NULL, payload_ref TEXT NOT NULL REFERENCES payloads(id), payload_bytes INTEGER NOT NULL,
|
|
7
|
+
delivery TEXT NOT NULL CHECK(delivery IN ('queue','steer')),
|
|
8
|
+
state TEXT NOT NULL CHECK(state IN ('accepted','waiting_slot','initializing','session_queue','running','completed','failed','interrupted')),
|
|
9
|
+
owner TEXT, token INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, error TEXT,
|
|
11
|
+
UNIQUE(session_id,event_id)
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS message_commands_dispatch_order ON message_commands(session_id,state,stream_id,delivery);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS message_commands_recent ON message_commands(session_id,stream_id DESC);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS message_commands_pending ON message_commands(state,created_at,id);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS message_commands_session ON message_commands(session_id,state,created_at,id);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS message_commands_lease ON message_commands(lease_until) WHERE owner IS NOT NULL;
|
|
18
|
+
CREATE INDEX IF NOT EXISTS message_commands_health_scope ON message_commands(state,delivery,room_id,session_id,stream_id,created_at);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS event_log_message_command_terminal ON event_log(session_id,event_id,type,stream_id)
|
|
20
|
+
WHERE event_id IS NOT NULL AND type IN ('message_finished','session_error','message_steered');
|
|
21
|
+
CREATE TABLE IF NOT EXISTS message_command_stats (
|
|
22
|
+
state TEXT NOT NULL, delivery TEXT NOT NULL, command_count INTEGER NOT NULL, payload_bytes INTEGER NOT NULL,
|
|
23
|
+
PRIMARY KEY(state,delivery)
|
|
24
|
+
);
|
|
25
|
+
CREATE TRIGGER IF NOT EXISTS message_command_stats_insert AFTER INSERT ON message_commands BEGIN
|
|
26
|
+
INSERT INTO message_command_stats(state,delivery,command_count,payload_bytes) VALUES(NEW.state,NEW.delivery,1,NEW.payload_bytes)
|
|
27
|
+
ON CONFLICT(state,delivery) DO UPDATE SET command_count=command_count+1,payload_bytes=payload_bytes+NEW.payload_bytes;
|
|
28
|
+
END;
|
|
29
|
+
CREATE TRIGGER IF NOT EXISTS message_command_stats_delete AFTER DELETE ON message_commands BEGIN
|
|
30
|
+
UPDATE message_command_stats SET command_count=command_count-1,payload_bytes=payload_bytes-OLD.payload_bytes WHERE state=OLD.state AND delivery=OLD.delivery;
|
|
31
|
+
END;
|
|
32
|
+
CREATE TRIGGER IF NOT EXISTS message_command_stats_update AFTER UPDATE OF state,delivery,payload_bytes ON message_commands
|
|
33
|
+
WHEN OLD.state<>NEW.state OR OLD.delivery<>NEW.delivery OR OLD.payload_bytes<>NEW.payload_bytes BEGIN
|
|
34
|
+
UPDATE message_command_stats SET command_count=command_count-1,payload_bytes=payload_bytes-OLD.payload_bytes WHERE state=OLD.state AND delivery=OLD.delivery;
|
|
35
|
+
INSERT INTO message_command_stats(state,delivery,command_count,payload_bytes) VALUES(NEW.state,NEW.delivery,1,NEW.payload_bytes)
|
|
36
|
+
ON CONFLICT(state,delivery) DO UPDATE SET command_count=command_count+1,payload_bytes=payload_bytes+NEW.payload_bytes;
|
|
37
|
+
END;
|
|
38
|
+
CREATE TABLE IF NOT EXISTS message_dispatch_clock (id INTEGER PRIMARY KEY CHECK(id=1), sequence INTEGER NOT NULL);
|
|
39
|
+
INSERT OR IGNORE INTO message_dispatch_clock VALUES (1,0);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS message_dispatch_rooms (room_id TEXT PRIMARY KEY, sequence INTEGER NOT NULL);
|
|
41
|
+
INSERT INTO message_command_stats(state,delivery,command_count,payload_bytes)
|
|
42
|
+
SELECT state,delivery,count(*),COALESCE(sum(payload_bytes),0) FROM message_commands
|
|
43
|
+
WHERE NOT EXISTS (SELECT 1 FROM message_command_stats) GROUP BY state,delivery;
|
|
44
|
+
`;
|
|
45
|
+
export const MESSAGE_COMMAND_LIMITS = Object.freeze({
|
|
46
|
+
queue: { count: 1000, roomCount: 256, sessionCount: 64, bytes: 64 * 1024 * 1024, roomBytes: 16 * 1024 * 1024, sessionBytes: 4 * 1024 * 1024, ageMs: 60 * 60 * 1000, roomAgeMs: 15 * 60 * 1000, sessionAgeMs: 10 * 60 * 1000 },
|
|
47
|
+
steer: { count: 64, roomCount: 16, sessionCount: 4, bytes: 4 * 1024 * 1024, roomBytes: 1024 * 1024, sessionBytes: 1024 * 1024, ageMs: 60 * 1000, roomAgeMs: 60 * 1000, sessionAgeMs: 60 * 1000 },
|
|
48
|
+
dispatch: { queue: 10, roomQueue: 5, steer: 2, roomSteer: 1 },
|
|
49
|
+
});
|
|
50
|
+
const active = "'accepted','waiting_slot','initializing','session_queue','running'";
|
|
51
|
+
const unstarted = "'accepted','waiting_slot'";
|
|
52
|
+
const terminalEvidenceSql = `(SELECT CASE
|
|
53
|
+
WHEN count(DISTINCT CASE WHEN e.type='session_error' THEN 'failed' ELSE 'completed' END)=1
|
|
54
|
+
THEN max(CASE WHEN e.type='session_error' THEN 'failed' ELSE 'completed' END) END
|
|
55
|
+
FROM event_log e WHERE e.session_id=message_commands.session_id AND e.event_id=message_commands.event_id
|
|
56
|
+
AND e.type IN ('message_finished','session_error','message_steered'))`;
|
|
57
|
+
/** Durable receipts are independent of optional trace/telemetry retention. Only the storage worker owns this store. */
|
|
58
|
+
export class MessageCommandStore {
|
|
59
|
+
store;
|
|
60
|
+
constructor(store) {
|
|
61
|
+
this.store = store;
|
|
62
|
+
}
|
|
63
|
+
fingerprint(input) {
|
|
64
|
+
if (Buffer.byteLength(input.text) > 1024 * 1024)
|
|
65
|
+
throw domainError("command_too_large", "Message exceeds the durable command byte limit.");
|
|
66
|
+
return createHash("sha256").update(JSON.stringify(input)).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
prepare(input) {
|
|
69
|
+
return {
|
|
70
|
+
fingerprint: this.fingerprint(input),
|
|
71
|
+
payload: this.store.payloads.preparePayload({ value: input.text, contentType: "text/plain", retentionClass: "message_command" }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
find(key, fingerprint) {
|
|
75
|
+
const row = this.store.db.prepare("SELECT * FROM message_commands WHERE request_key = ?").get(key);
|
|
76
|
+
if (row && fingerprint && row.fingerprint !== fingerprint)
|
|
77
|
+
throw domainError("command_conflict", "Client transaction ID is already bound to different message content, delivery, or session.");
|
|
78
|
+
return row && receipt(row);
|
|
79
|
+
}
|
|
80
|
+
list(sessionId) {
|
|
81
|
+
const pending = this.store.db.prepare(`SELECT * FROM message_commands WHERE session_id=? AND state IN (${active},'interrupted') ORDER BY stream_id DESC LIMIT 70`).all(sessionId);
|
|
82
|
+
const terminal = this.store.db.prepare("SELECT * FROM message_commands WHERE session_id=? AND state IN ('completed','failed') ORDER BY stream_id DESC LIMIT 64").all(sessionId);
|
|
83
|
+
return [...pending, ...terminal].sort((a, b) => b.stream_id - a.stream_id).map(receipt);
|
|
84
|
+
}
|
|
85
|
+
queueStatus(sessionId) {
|
|
86
|
+
const rows = this.store.db.prepare(`SELECT state,delivery,payload_bytes,created_at FROM message_commands WHERE session_id=? AND state IN (${active}) LIMIT 69`).all(sessionId);
|
|
87
|
+
const blocker = this.store.db.prepare("SELECT id,created_at FROM message_commands WHERE session_id=? AND state='interrupted' ORDER BY stream_id LIMIT 1").get(sessionId);
|
|
88
|
+
const summarize = (delivery) => {
|
|
89
|
+
const scoped = rows.filter(row => row.delivery === delivery), limits = MESSAGE_COMMAND_LIMITS[delivery];
|
|
90
|
+
return { count: scoped.length, bytes: scoped.reduce((sum, row) => sum + row.payload_bytes, 0), oldestWaitMs: Math.max(0, ...scoped.filter(row => row.state === "accepted" || row.state === "waiting_slot").map(row => Date.now() - row.created_at)), limits: { count: limits.sessionCount, bytes: limits.sessionBytes, waitMs: limits.sessionAgeMs } };
|
|
91
|
+
};
|
|
92
|
+
return { queue: summarize("queue"), steer: summarize("steer"), ...(blocker ? { reconciliation: { required: true, retryable: false, scope: "session", blockingCommandId: blocker.id, blockedSince: blocker.created_at, nextAction: `pibo debug message-queue inspect --session ${sessionId}` } } : {}) };
|
|
93
|
+
}
|
|
94
|
+
get(id) {
|
|
95
|
+
const row = this.store.db.prepare("SELECT * FROM message_commands WHERE id = ?").get(id);
|
|
96
|
+
return row && receipt(row);
|
|
97
|
+
}
|
|
98
|
+
terminalOutcome(sessionId, eventId) {
|
|
99
|
+
const row = this.store.db.prepare(`SELECT CASE WHEN count(DISTINCT CASE WHEN type='session_error' THEN 'failed' ELSE 'completed' END)=1 THEN max(CASE WHEN type='session_error' THEN 'failed' ELSE 'completed' END) END state FROM event_log WHERE session_id=? AND event_id=? AND type IN ('message_finished','session_error','message_steered')`).get(sessionId, eventId);
|
|
100
|
+
return row?.state;
|
|
101
|
+
}
|
|
102
|
+
terminalEvidence(sessionId, eventId) {
|
|
103
|
+
return this.store.db.prepare(`SELECT stream_id,type,CASE WHEN type='session_error' THEN 'failed' ELSE 'completed' END state FROM event_log
|
|
104
|
+
WHERE session_id=? AND event_id=? AND type IN ('message_finished','session_error','message_steered') ORDER BY stream_id LIMIT 8`).all(sessionId, eventId)
|
|
105
|
+
.map(row => ({ streamId: row.stream_id, type: row.type, state: row.state }));
|
|
106
|
+
}
|
|
107
|
+
assertAdmissionUnblocked(sessionId, delivery, beforeStreamId = Number.MAX_SAFE_INTEGER) {
|
|
108
|
+
if (delivery !== "queue")
|
|
109
|
+
return;
|
|
110
|
+
const blocker = this.store.db.prepare("SELECT id,created_at FROM message_commands WHERE session_id=? AND state='interrupted' AND stream_id<? ORDER BY stream_id LIMIT 1").get(sessionId, beforeStreamId);
|
|
111
|
+
if (blocker)
|
|
112
|
+
throw domainError("command_reconciliation_required", "A previous interrupted message requires operator review before this session can accept more messages.", { retryable: false, scope: "session", blockingCommandId: blocker.id, blockedSince: blocker.created_at, oldestWaitAgeMs: Math.max(0, Date.now() - blocker.created_at), nextAction: `pibo debug message-queue inspect --session ${sessionId}` });
|
|
113
|
+
}
|
|
114
|
+
insert(input) {
|
|
115
|
+
const prior = this.find(input.key, input.fingerprint);
|
|
116
|
+
if (prior)
|
|
117
|
+
return prior;
|
|
118
|
+
this.assertAdmissionUnblocked(input.sessionId, input.delivery, input.streamId);
|
|
119
|
+
const limit = MESSAGE_COMMAND_LIMITS[input.delivery];
|
|
120
|
+
const rows = this.store.db.prepare(`SELECT c.session_id,c.room_id,c.payload_bytes,c.created_at,c.state,
|
|
121
|
+
NOT EXISTS(SELECT 1 FROM message_commands p WHERE p.session_id=c.session_id AND p.state='interrupted' AND p.stream_id<c.stream_id) dispatchable
|
|
122
|
+
FROM message_commands c WHERE c.state IN (${active}) AND c.delivery=? LIMIT ?`).all(input.delivery, limit.count + 1);
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const exceeds = (scope, count, bytes, ageMs) => scope.length >= count
|
|
125
|
+
|| scope.reduce((n, r) => n + r.payload_bytes, input.payload.byteSize) > bytes
|
|
126
|
+
|| scope.some(r => r.dispatchable === 1 && (r.state === "accepted" || r.state === "waiting_slot") && now - r.created_at >= ageMs);
|
|
127
|
+
if (exceeds(rows, limit.count, limit.bytes, limit.ageMs)
|
|
128
|
+
|| exceeds(rows.filter(r => r.room_id === input.roomId), limit.roomCount, limit.roomBytes, limit.roomAgeMs)
|
|
129
|
+
|| exceeds(rows.filter(r => r.session_id === input.sessionId), limit.sessionCount, limit.sessionBytes, limit.sessionAgeMs)) {
|
|
130
|
+
throw domainError("command_overloaded", "Durable message queue count, byte or dispatchable wait-age capacity reached; retry the same transaction later.", { retryable: true });
|
|
131
|
+
}
|
|
132
|
+
const id = `cmd_${randomUUID()}`;
|
|
133
|
+
const payload = this.store.payloads.commitPreparedPayload(input.payload);
|
|
134
|
+
this.store.db.prepare(`INSERT INTO message_commands (id,request_key,fingerprint,session_id,room_id,event_id,stream_id,payload_ref,payload_bytes,delivery,state,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,'accepted',?,?)`).run(id, input.key, input.fingerprint, input.sessionId, input.roomId, input.eventId, input.streamId, payload.id, payload.byteSize, input.delivery, now, now);
|
|
135
|
+
return this.get(id);
|
|
136
|
+
}
|
|
137
|
+
terminalizeBlockedSuccessors(now, limit = 100) {
|
|
138
|
+
const bounded = Math.max(1, Math.min(1000, Math.trunc(limit) || 1));
|
|
139
|
+
return Number(this.store.db.prepare(`UPDATE message_commands SET state='failed',error='Not dispatched: an earlier interrupted message requires reconciliation.',owner = NULL,lease_until=0,updated_at=?
|
|
140
|
+
WHERE id IN (SELECT c.id FROM message_commands c WHERE c.state IN (${unstarted}) AND c.delivery='queue' AND c.owner IS NULL AND EXISTS(
|
|
141
|
+
SELECT 1 FROM message_commands p WHERE p.session_id=c.session_id AND p.state='interrupted' AND p.stream_id<c.stream_id
|
|
142
|
+
) ORDER BY c.created_at,c.id LIMIT ?)`).run(now, bounded).changes);
|
|
143
|
+
}
|
|
144
|
+
/** Bounded, idempotent startup repair. Ambiguous rows cannot starve later evidence-backed rows. */
|
|
145
|
+
reconcileInterrupted(limit = 1000) {
|
|
146
|
+
const bounded = Math.max(1, Math.min(1000, Math.trunc(limit) || 1));
|
|
147
|
+
let rows = [];
|
|
148
|
+
try {
|
|
149
|
+
rows = this.store.db.prepare(`SELECT id FROM message_commands WHERE state='interrupted' AND ${terminalEvidenceSql} IN ('completed','failed') ORDER BY updated_at,id LIMIT ?`).all(bounded);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return { examined: 0, reconciled: 0, blockedSuccessorsFailed: 0, ambiguous: 0, errors: 1 };
|
|
153
|
+
}
|
|
154
|
+
let reconciled = 0, errors = 0;
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
try {
|
|
157
|
+
reconciled += this.store.transaction(() => Number(this.store.db.prepare(`UPDATE message_commands SET state=${terminalEvidenceSql},error=NULL,owner = NULL,lease_until=0,updated_at=? WHERE id=? AND state='interrupted' AND ${terminalEvidenceSql} IN ('completed','failed')`).run(Date.now(), row.id).changes));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
errors++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
let blockedSuccessorsFailed = 0;
|
|
164
|
+
try {
|
|
165
|
+
blockedSuccessorsFailed = this.store.transaction(() => this.terminalizeBlockedSuccessors(Date.now(), bounded));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
errors++;
|
|
169
|
+
}
|
|
170
|
+
const ambiguous = Number(this.store.db.prepare("SELECT count(*) n FROM message_commands WHERE state='interrupted'").get().n);
|
|
171
|
+
return { examined: rows.length, reconciled, blockedSuccessorsFailed, ambiguous, errors };
|
|
172
|
+
}
|
|
173
|
+
recoverExpiredLeasesInTransaction(now, afterSelection) {
|
|
174
|
+
const rows = this.store.db.prepare("SELECT id FROM message_commands WHERE owner IS NOT NULL AND lease_until<=? ORDER BY lease_until,id LIMIT 100").all(now);
|
|
175
|
+
afterSelection?.();
|
|
176
|
+
let changed = 0;
|
|
177
|
+
for (const row of rows) {
|
|
178
|
+
changed += Number(this.store.db.prepare(`UPDATE message_commands SET
|
|
179
|
+
state=CASE WHEN ${terminalEvidenceSql} IN ('completed','failed') THEN ${terminalEvidenceSql} WHEN state='waiting_slot' THEN 'accepted' ELSE 'interrupted' END,
|
|
180
|
+
error=CASE WHEN ${terminalEvidenceSql} IN ('completed','failed') OR state='waiting_slot' THEN NULL ELSE 'Runtime ownership expired; execution requires reconciliation.' END,
|
|
181
|
+
owner = NULL,lease_until=0,updated_at=? WHERE id=? AND owner IS NOT NULL AND lease_until<=? AND state IN (${active})`).run(now, row.id, now).changes);
|
|
182
|
+
}
|
|
183
|
+
this.terminalizeBlockedSuccessors(now);
|
|
184
|
+
return changed;
|
|
185
|
+
}
|
|
186
|
+
/** Public for deterministic recovery tests; production callers use claim(). */
|
|
187
|
+
recoverExpiredLeases(now = Date.now(), barrier) {
|
|
188
|
+
return this.store.transaction(() => this.recoverExpiredLeasesInTransaction(now, barrier));
|
|
189
|
+
}
|
|
190
|
+
claim(owner, leaseMs) {
|
|
191
|
+
const limits = MESSAGE_COMMAND_LIMITS.dispatch;
|
|
192
|
+
const candidateSql = `WITH occupied AS (
|
|
193
|
+
SELECT room_id,delivery FROM message_commands WHERE owner IS NOT NULL AND state IN (${active})
|
|
194
|
+
) SELECT c.* FROM message_commands c LEFT JOIN message_dispatch_rooms r ON r.room_id=c.room_id
|
|
195
|
+
WHERE c.state='accepted'
|
|
196
|
+
AND (SELECT count(*) FROM occupied o WHERE o.delivery=c.delivery) < CASE c.delivery WHEN 'steer' THEN ${limits.steer} ELSE ${limits.queue} END
|
|
197
|
+
AND (SELECT count(*) FROM occupied o WHERE o.delivery=c.delivery AND o.room_id=c.room_id) < CASE c.delivery WHEN 'steer' THEN ${limits.roomSteer} ELSE ${limits.roomQueue} END
|
|
198
|
+
AND NOT EXISTS (SELECT 1 FROM message_commands p WHERE p.session_id=c.session_id AND p.state IN (${active},'interrupted') AND p.stream_id<c.stream_id AND (c.delivery='queue' OR p.delivery='steer'))
|
|
199
|
+
ORDER BY CASE c.delivery WHEN 'steer' THEN 0 ELSE 1 END,COALESCE(r.sequence,0),c.stream_id LIMIT 1`;
|
|
200
|
+
if (!this.store.db.prepare(candidateSql).get() && !this.store.db.prepare("SELECT 1 FROM message_commands WHERE owner IS NOT NULL AND lease_until <= ? LIMIT 1").get(Date.now()))
|
|
201
|
+
return undefined;
|
|
202
|
+
const row = this.store.transaction(() => {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
this.recoverExpiredLeasesInTransaction(now);
|
|
205
|
+
const candidate = this.store.db.prepare(candidateSql).get();
|
|
206
|
+
if (!candidate)
|
|
207
|
+
return undefined;
|
|
208
|
+
this.store.db.prepare("UPDATE message_dispatch_clock SET sequence=sequence+1 WHERE id=1").run();
|
|
209
|
+
this.store.db.prepare("INSERT INTO message_dispatch_rooms(room_id,sequence) SELECT ?,sequence FROM message_dispatch_clock WHERE id=1 ON CONFLICT(room_id) DO UPDATE SET sequence=excluded.sequence").run(candidate.room_id);
|
|
210
|
+
this.store.db.prepare("UPDATE message_commands SET state='waiting_slot',owner = ?,token=token+1,lease_until=?,updated_at=? WHERE id=?").run(owner, now + leaseMs, now, candidate.id);
|
|
211
|
+
return this.store.db.prepare("SELECT * FROM message_commands WHERE id=?").get(candidate.id);
|
|
212
|
+
});
|
|
213
|
+
if (!row)
|
|
214
|
+
return undefined;
|
|
215
|
+
try {
|
|
216
|
+
const text = Buffer.from(this.store.payloads.readPayloadBytesBounded(row.payload_ref, 1024 * 1024)).toString("utf8");
|
|
217
|
+
return { ...receipt(row), token: row.token, text, delivery: row.delivery };
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
this.transition(row.id, owner, row.token, "failed", "Durable message payload is unavailable or corrupt.");
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
cancelPending(sessionId) {
|
|
225
|
+
return Number(this.store.db.prepare("UPDATE message_commands SET state='failed',error='Cancelled before runtime dispatch.',owner = NULL,lease_until=0,updated_at=? WHERE session_id=? AND state IN ('accepted','waiting_slot')").run(Date.now(), sessionId).changes);
|
|
226
|
+
}
|
|
227
|
+
transition(id, owner, token, state, error) {
|
|
228
|
+
return Number(this.store.db.prepare(`UPDATE message_commands SET state=?,error=?,updated_at=?,owner = CASE WHEN ? IN ('completed','failed','interrupted') THEN NULL ELSE owner END,lease_until=CASE WHEN ? IN ('completed','failed','interrupted') THEN 0 ELSE lease_until END WHERE id=? AND owner = ? AND token=? AND lease_until>? AND state IN (${active})`).run(state, error?.slice(0, 500) ?? null, Date.now(), state, state, id, owner, token, Date.now()).changes) === 1;
|
|
229
|
+
}
|
|
230
|
+
heartbeat(id, owner, token, leaseMs) {
|
|
231
|
+
return Number(this.store.db.prepare(`UPDATE message_commands SET lease_until=? WHERE id=? AND owner = ? AND token=? AND lease_until>? AND state IN (${active})`).run(Date.now() + leaseMs, id, owner, token, Date.now()).changes) === 1;
|
|
232
|
+
}
|
|
233
|
+
recordOutput(sessionId, eventId, type) {
|
|
234
|
+
if (!eventId)
|
|
235
|
+
return;
|
|
236
|
+
const states = { message_queued: "session_queue", message_started: "running", message_finished: "completed", session_error: "failed", message_steered: "completed" };
|
|
237
|
+
const state = states[type];
|
|
238
|
+
if (!state)
|
|
239
|
+
return;
|
|
240
|
+
const eligible = state === "session_queue" ? "'initializing','waiting_slot'" : state === "running" ? "'initializing','waiting_slot','session_queue'" : `${active},'interrupted'`;
|
|
241
|
+
this.store.db.prepare(`UPDATE message_commands SET state=?,error=NULL,updated_at=?,owner = CASE WHEN ? IN ('completed','failed') THEN NULL ELSE owner END,lease_until=CASE WHEN ? IN ('completed','failed') THEN 0 ELSE lease_until END WHERE session_id=? AND event_id=? AND state IN (${eligible})`).run(state, Date.now(), state, state, sessionId, eventId);
|
|
242
|
+
}
|
|
243
|
+
health(now = Date.now(), scopeLimit = 20) {
|
|
244
|
+
const bounded = Math.max(1, Math.min(100, Math.trunc(scopeLimit) || 20));
|
|
245
|
+
const activeRowsLimit = MESSAGE_COMMAND_LIMITS.queue.count + MESSAGE_COMMAND_LIMITS.steer.count + 1;
|
|
246
|
+
const counts = this.store.db.prepare("SELECT state,delivery,command_count count,payload_bytes bytes FROM message_command_stats WHERE command_count>0 ORDER BY state,delivery").all();
|
|
247
|
+
const interrupted = Number(this.store.db.prepare("SELECT count(*) n FROM message_commands WHERE state='interrupted'").get().n);
|
|
248
|
+
const expired = Number(this.store.db.prepare("SELECT count(*) n FROM message_commands WHERE owner IS NOT NULL AND lease_until<=?").get(now).n);
|
|
249
|
+
const blockedRows = this.store.db.prepare(`SELECT c.session_id,c.room_id,c.created_at,p.id blocking_id,p.created_at blocked_since
|
|
250
|
+
FROM message_commands c JOIN message_commands p ON p.id=(SELECT p2.id FROM message_commands p2 WHERE p2.session_id=c.session_id AND p2.state='interrupted' AND p2.stream_id<c.stream_id ORDER BY p2.stream_id LIMIT 1)
|
|
251
|
+
WHERE c.state IN (${unstarted}) AND c.delivery='queue' ORDER BY c.created_at,c.id LIMIT ?`).all(bounded);
|
|
252
|
+
const blockedCount = Number(this.store.db.prepare(`SELECT count(*) n FROM message_commands c WHERE c.state IN (${unstarted}) AND c.delivery='queue' AND EXISTS(SELECT 1 FROM message_commands p WHERE p.session_id=c.session_id AND p.state='interrupted' AND p.stream_id<c.stream_id)`).get().n);
|
|
253
|
+
const oldestDispatchable = this.store.db.prepare(`SELECT created_at FROM message_commands c WHERE c.state IN (${unstarted}) AND NOT EXISTS(SELECT 1 FROM message_commands p WHERE p.session_id=c.session_id AND p.state='interrupted' AND p.stream_id<c.stream_id) ORDER BY created_at LIMIT 1`).get();
|
|
254
|
+
const scopes = new Map();
|
|
255
|
+
for (const row of blockedRows) {
|
|
256
|
+
const prior = scopes.get(row.session_id);
|
|
257
|
+
if (prior)
|
|
258
|
+
prior.blockedSuccessors++;
|
|
259
|
+
else
|
|
260
|
+
scopes.set(row.session_id, { sessionId: row.session_id, roomId: row.room_id, blockingCommandId: row.blocking_id, blockedSince: row.blocked_since, blockedSuccessors: 1 });
|
|
261
|
+
}
|
|
262
|
+
if (interrupted > 0) {
|
|
263
|
+
for (const row of this.store.db.prepare("SELECT session_id,room_id,id,created_at FROM message_commands WHERE state='interrupted' ORDER BY created_at LIMIT ?").all(bounded)) {
|
|
264
|
+
if (!scopes.has(row.session_id))
|
|
265
|
+
scopes.set(row.session_id, { sessionId: row.session_id, roomId: row.room_id, blockingCommandId: row.id, blockedSince: row.created_at, blockedSuccessors: 0 });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const activeRows = this.store.db.prepare(`SELECT session_id,room_id,delivery,payload_bytes,created_at,state,NOT EXISTS(SELECT 1 FROM message_commands p WHERE p.session_id=c.session_id AND p.state='interrupted' AND p.stream_id<c.stream_id) dispatchable FROM message_commands c WHERE state IN (${active}) ORDER BY created_at,id LIMIT ?`).all(activeRowsLimit);
|
|
269
|
+
const capacityReasons = [];
|
|
270
|
+
const activeStates = new Set(["accepted", "waiting_slot", "initializing", "session_queue", "running"]);
|
|
271
|
+
const globalTotals = (delivery) => counts.filter(row => row.delivery === delivery && activeStates.has(row.state)).reduce((total, row) => ({ count: total.count + row.count, bytes: total.bytes + row.bytes }), { count: 0, bytes: 0 });
|
|
272
|
+
const summarizeCapacity = (rows, delivery, scope) => { const selected = rows.filter(row => row.delivery === delivery), limits = MESSAGE_COMMAND_LIMITS[delivery], countLimit = scope === "global" ? limits.count : scope === "room" ? limits.roomCount : limits.sessionCount, byteLimit = scope === "global" ? limits.bytes : scope === "room" ? limits.roomBytes : limits.sessionBytes, ageLimit = scope === "global" ? limits.ageMs : scope === "room" ? limits.roomAgeMs : limits.sessionAgeMs, totals = scope === "global" ? globalTotals(delivery) : { count: selected.length, bytes: selected.reduce((sum, row) => sum + row.payload_bytes, 0) }, count = totals.count, bytes = totals.bytes, oldestDispatchableWaitMs = Math.max(0, ...selected.filter(row => row.dispatchable === 1 && (row.state === "accepted" || row.state === "waiting_slot")).map(row => now - row.created_at)), reasons = [...(count >= countLimit ? [`count ${count}/${countLimit}`] : []), ...(bytes >= byteLimit ? [`bytes ${bytes}/${byteLimit}`] : []), ...(oldestDispatchableWaitMs >= ageLimit ? [`dispatchable wait ${oldestDispatchableWaitMs}ms/${ageLimit}ms`] : [])]; return { delivery, count, bytes, oldestDispatchableWaitMs, available: reasons.length === 0, degradedReasons: reasons }; };
|
|
273
|
+
const globalCapacity = ["queue", "steer"].map(delivery => summarizeCapacity(activeRows, delivery, "global"));
|
|
274
|
+
const roomKeys = [...new Set(activeRows.map(row => row.room_id))], sessionKeys = [...new Set(activeRows.map(row => row.session_id))];
|
|
275
|
+
const roomCapacity = roomKeys.flatMap(roomId => ["queue", "steer"].map(delivery => ({ roomId, ...summarizeCapacity(activeRows.filter(row => row.room_id === roomId), delivery, "room") }))).filter(row => !row.available).slice(0, bounded);
|
|
276
|
+
const sessionCapacity = sessionKeys.flatMap(sessionId => ["queue", "steer"].map(delivery => ({ sessionId, roomId: activeRows.find(row => row.session_id === sessionId)?.room_id ?? "unknown", ...summarizeCapacity(activeRows.filter(row => row.session_id === sessionId), delivery, "session") }))).filter(row => !row.available).slice(0, bounded);
|
|
277
|
+
for (const row of globalCapacity.filter(row => !row.available))
|
|
278
|
+
capacityReasons.push(`global ${row.delivery} admission degraded: ${row.degradedReasons.join(", ")}`);
|
|
279
|
+
for (const row of roomCapacity)
|
|
280
|
+
capacityReasons.push(`room ${row.roomId} ${row.delivery} admission degraded: ${row.degradedReasons.join(", ")}`);
|
|
281
|
+
for (const row of sessionCapacity)
|
|
282
|
+
capacityReasons.push(`session ${row.sessionId} ${row.delivery} admission degraded: ${row.degradedReasons.join(", ")}`);
|
|
283
|
+
const degradedReasons = [...(interrupted ? [`${interrupted} interrupted predecessor(s) require reconciliation`] : []), ...(blockedCount ? [`${blockedCount} successor(s) are blocked by FIFO`] : []), ...(expired ? [`${expired} owned lease(s) are expired`] : []), ...capacityReasons];
|
|
284
|
+
return { status: degradedReasons.length ? "degraded" : "healthy", storage: { available: true }, counts, interruptedPredecessors: interrupted, blockedSuccessors: blockedCount, expiredOwnedLeases: expired, oldestDispatchableWaitMs: oldestDispatchable ? Math.max(0, now - oldestDispatchable.created_at) : 0, oldestBlockedWaitMs: blockedRows.length ? Math.max(...blockedRows.map(row => Math.max(0, now - row.created_at))) : 0, affectedScopes: [...scopes.values()].slice(0, bounded), limits: MESSAGE_COMMAND_LIMITS, degradedReasons, admissionCapacity: { global: globalCapacity, rooms: roomCapacity, sessions: sessionCapacity }, bounded: { scopeLimit: bounded, activeRowsLimit, activeRowsTruncated: activeRows.length === activeRowsLimit, affectedScopesTruncated: interrupted > bounded || scopes.size > bounded || blockedCount > blockedRows.length } };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function receipt(row) {
|
|
288
|
+
return { id: row.id, sessionId: row.session_id, roomId: row.room_id, eventId: row.event_id, streamId: row.stream_id, state: row.state, createdAt: row.created_at, updatedAt: row.updated_at, ...(row.error ? { error: row.error } : {}) };
|
|
289
|
+
}
|
|
290
|
+
function domainError(code, message, details = {}) { return Object.assign(new Error(message), { code, ...details }); }
|