@llblab/pi-telegram 0.51.6 → 0.52.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/AGENTS.md +15 -12
- package/BACKLOG.md +16 -9
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +17 -6
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +5 -7
- package/dist/lib/bindings.d.ts +5 -1
- package/dist/lib/bindings.js +7 -1
- package/dist/lib/bus-api.js +8 -5
- package/dist/lib/bus-follower.d.ts +54 -56
- package/dist/lib/bus-follower.js +206 -139
- package/dist/lib/bus-leader.d.ts +21 -2
- package/dist/lib/bus-leader.js +181 -25
- package/dist/lib/bus-transport.d.ts +4 -1
- package/dist/lib/bus-transport.js +28 -4
- package/dist/lib/bus.d.ts +52 -56
- package/dist/lib/bus.js +78 -246
- package/dist/lib/channel-posts.d.ts +1 -3
- package/dist/lib/channel-posts.js +23 -26
- package/dist/lib/command-templates.d.ts +0 -5
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/commands.d.ts +0 -23
- package/dist/lib/commands.js +15 -15
- package/dist/lib/config.d.ts +0 -17
- package/dist/lib/config.js +12 -14
- package/dist/lib/delivery.d.ts +0 -4
- package/dist/lib/delivery.js +2 -5
- package/dist/lib/extension.js +258 -61
- package/dist/lib/generative-apps.d.ts +0 -19
- package/dist/lib/inbound.d.ts +0 -2
- package/dist/lib/inbound.js +2 -2
- package/dist/lib/journal.d.ts +183 -16
- package/dist/lib/journal.js +1175 -248
- package/dist/lib/keyboard.d.ts +0 -2
- package/dist/lib/keyboard.js +2 -2
- package/dist/lib/lifecycle.js +1 -1
- package/dist/lib/locks.d.ts +100 -12
- package/dist/lib/locks.js +380 -14
- package/dist/lib/logging.d.ts +30 -12
- package/dist/lib/logging.js +129 -72
- package/dist/lib/media.d.ts +28 -5
- package/dist/lib/media.js +26 -8
- package/dist/lib/menu-model.d.ts +0 -11
- package/dist/lib/menu-model.js +8 -8
- package/dist/lib/menu-settings.d.ts +0 -18
- package/dist/lib/menu-settings.js +18 -18
- package/dist/lib/menu-status.d.ts +4 -4
- package/dist/lib/menu-status.js +12 -7
- package/dist/lib/menu-thinking.d.ts +1 -1
- package/dist/lib/menu-thinking.js +1 -1
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +4 -3
- package/dist/lib/outbound-attachments.d.ts +0 -1
- package/dist/lib/outbound-attachments.js +1 -1
- package/dist/lib/outbound-buttons.js +1 -8
- package/dist/lib/outbound-markup.d.ts +1 -6
- package/dist/lib/outbound-markup.js +4 -21
- package/dist/lib/outbound.d.ts +0 -8
- package/dist/lib/outbound.js +5 -5
- package/dist/lib/paths.d.ts +50 -7
- package/dist/lib/paths.js +155 -17
- package/dist/lib/pi.d.ts +1 -0
- package/dist/lib/pi.js +2 -1
- package/dist/lib/polling.d.ts +0 -5
- package/dist/lib/polling.js +5 -5
- package/dist/lib/preview.d.ts +0 -3
- package/dist/lib/preview.js +3 -3
- package/dist/lib/prompt-templates.d.ts +0 -1
- package/dist/lib/prompt-templates.js +1 -1
- package/dist/lib/queue.d.ts +5 -15
- package/dist/lib/queue.js +94 -11
- package/dist/lib/recovery.d.ts +29 -9
- package/dist/lib/recovery.js +104 -33
- package/dist/lib/replies.d.ts +0 -4
- package/dist/lib/replies.js +1 -1
- package/dist/lib/routing.d.ts +74 -8
- package/dist/lib/routing.js +2280 -307
- package/dist/lib/sections.d.ts +0 -2
- package/dist/lib/sections.js +1 -1
- package/dist/lib/status.d.ts +44 -9
- package/dist/lib/status.js +149 -27
- package/dist/lib/sync.d.ts +0 -5
- package/dist/lib/sync.js +5 -4
- package/dist/lib/telegram-api.d.ts +13 -3
- package/dist/lib/telegram-api.js +38 -23
- package/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/dist/lib/thread-cleanup-manager.js +18 -21
- package/dist/lib/thread-naming.d.ts +32 -5
- package/dist/lib/thread-naming.js +191 -7
- package/dist/lib/thread-reconciler.d.ts +2 -0
- package/dist/lib/thread-reconciler.js +1 -1
- package/dist/lib/threads.d.ts +293 -59
- package/dist/lib/threads.js +1689 -369
- package/dist/lib/turns.d.ts +0 -1
- package/dist/lib/turns.js +1 -1
- package/dist/lib/updates.d.ts +191 -28
- package/dist/lib/updates.js +1048 -114
- package/dist/lib/wire.d.ts +12 -0
- package/dist/lib/wire.js +21 -0
- package/dist/lib/workspace-admission.d.ts +25 -17
- package/dist/lib/workspace-admission.js +95 -20
- package/dist/lib/workspace-identity.d.ts +20 -0
- package/dist/lib/workspace-identity.js +103 -0
- package/dist/lib/workspace-retirement.d.ts +43 -8
- package/dist/lib/workspace-retirement.js +148 -52
- package/dist/package.json +6 -6
- package/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/docs/README.md +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +156 -36
- package/docs/callback-namespaces.md +1 -1
- package/docs/delivery.md +1 -1
- package/docs/generative-apps.md +2 -2
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +125 -19
- package/docs/public-api.md +3 -1
- package/docs/ui-style.md +23 -9
- package/docs/updates.md +1 -1
- package/lib/activity-verbosity.ts +5 -9
- package/lib/bindings.ts +7 -0
- package/lib/bus-api.ts +9 -4
- package/lib/bus-follower.ts +243 -224
- package/lib/bus-leader.ts +178 -31
- package/lib/bus-transport.ts +30 -8
- package/lib/bus.ts +110 -334
- package/lib/channel-posts.ts +29 -29
- package/lib/command-templates.ts +5 -5
- package/lib/commands.ts +15 -15
- package/lib/config.ts +12 -17
- package/lib/delivery.ts +2 -8
- package/lib/extension.ts +261 -72
- package/lib/generative-apps.ts +0 -22
- package/lib/inbound.ts +2 -2
- package/lib/journal.ts +1250 -325
- package/lib/keyboard.ts +2 -2
- package/lib/lifecycle.ts +1 -1
- package/lib/locks.ts +402 -23
- package/lib/logging.ts +151 -101
- package/lib/media.ts +51 -9
- package/lib/menu-model.ts +8 -8
- package/lib/menu-settings.ts +18 -18
- package/lib/menu-status.ts +11 -0
- package/lib/menu-thinking.ts +2 -2
- package/lib/menu.ts +5 -1
- package/lib/outbound-attachments.ts +1 -1
- package/lib/outbound-buttons.ts +1 -10
- package/lib/outbound-markup.ts +5 -24
- package/lib/outbound.ts +5 -5
- package/lib/paths.ts +177 -17
- package/lib/pi.ts +5 -2
- package/lib/polling.ts +5 -5
- package/lib/preview.ts +3 -3
- package/lib/prompt-templates.ts +1 -1
- package/lib/queue.ts +93 -12
- package/lib/recovery.ts +104 -44
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +2010 -375
- package/lib/sections.ts +1 -1
- package/lib/status.ts +156 -36
- package/lib/sync.ts +4 -4
- package/lib/telegram-api.ts +53 -33
- package/lib/thread-cleanup-manager.ts +24 -21
- package/lib/thread-naming.ts +267 -9
- package/lib/thread-reconciler.ts +3 -1
- package/lib/threads.ts +1736 -488
- package/lib/turns.ts +1 -1
- package/lib/updates.ts +1094 -151
- package/lib/wire.ts +28 -0
- package/lib/workspace-admission.ts +97 -47
- package/lib/workspace-identity.ts +147 -0
- package/lib/workspace-retirement.ts +188 -89
- package/package.json +6 -6
- package/scripts/audit-exports.mjs +100 -0
- package/scripts/check-downgrade.mjs +80 -43
- package/skills/generated-control-surface/SKILL.md +3 -3
- package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/dist/lib/updates.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Owns update extraction, authorization, execution planning, generation-fenced journal draining, and the public update-handler registry
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
|
+
import { isWireRecord as isTelegramUpdateAdmissionRecord, isNonEmptyWireString as isTelegramUpdateAdmissionString, } from "./wire.js";
|
|
7
9
|
import { createTelegramPrivateTarget, createTelegramThreadTarget, } from "./target.js";
|
|
8
|
-
import { TELEGRAM_UPDATE_JOURNAL_VERSION, TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION, TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH, areTelegramUpdateJournalQueueOwnersEqual, getTelegramUpdateJournalBindingPath, isTelegramUpdateJournalQueueOwnerProcess, parseTelegramUpdateJournalQueueOwner, } from "./journal.js";
|
|
10
|
+
import { TELEGRAM_UPDATE_JOURNAL_VERSION, TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION, TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION, isTelegramUpdateJournalLegacyFamilyVersion, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH, areTelegramUpdateJournalQueueOwnersEqual, createTelegramUpdateJournalEntryDigest, getTelegramUpdateJournalBindingPath, isTelegramUpdateJournalQueueOwnerProcess, parseTelegramUpdateJournalQueueOwner, } from "./journal.js";
|
|
9
11
|
import { areTelegramQueueAdmissionReceiptsEqual, createTelegramQueueHandoff, removeTelegramQueueItemByReceipt, } from "./queue.js";
|
|
10
12
|
import { createTelegramUserPairingRuntime, getTelegramAuthorizationState, } from "./config.js";
|
|
11
13
|
export const TELEGRAM_PRIORITY_REACTIONS = [
|
|
@@ -43,23 +45,6 @@ export function collectTelegramReactionEmojis(reactions) {
|
|
|
43
45
|
function getTelegramReactionEmoji(emojis, candidates) {
|
|
44
46
|
return candidates.find((emoji) => emojis.has(emoji));
|
|
45
47
|
}
|
|
46
|
-
export function getTelegramQueueReactionDisposition(reactions) {
|
|
47
|
-
const emojis = collectTelegramReactionEmojis(reactions);
|
|
48
|
-
const suppressionEmoji = getTelegramReactionEmoji(emojis, TELEGRAM_REMOVAL_REACTION_EMOJIS);
|
|
49
|
-
const priorityEmoji = getTelegramReactionEmoji(emojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
|
|
50
|
-
if (suppressionEmoji && priorityEmoji) {
|
|
51
|
-
return {
|
|
52
|
-
kind: "priority-suppressed",
|
|
53
|
-
priorityEmoji,
|
|
54
|
-
suppressionEmoji,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
if (suppressionEmoji)
|
|
58
|
-
return { kind: "suppressed", emoji: suppressionEmoji };
|
|
59
|
-
if (priorityEmoji)
|
|
60
|
-
return { kind: "priority", emoji: priorityEmoji };
|
|
61
|
-
return { kind: "default" };
|
|
62
|
-
}
|
|
63
48
|
function getTelegramQueueReactionTransition(oldReactions, newReactions) {
|
|
64
49
|
const oldEmojis = collectTelegramReactionEmojis(oldReactions);
|
|
65
50
|
const newEmojis = collectTelegramReactionEmojis(newReactions);
|
|
@@ -149,7 +134,7 @@ export function getAuthorizedTelegramGuestMessage(update) {
|
|
|
149
134
|
}
|
|
150
135
|
return guestMessage;
|
|
151
136
|
}
|
|
152
|
-
|
|
137
|
+
class TelegramForeignUpdateSettlementError extends Error {
|
|
153
138
|
settlement;
|
|
154
139
|
constructor(operation, settlement) {
|
|
155
140
|
super(`Telegram ${operation} forwarding did not settle: ${settlement.failureClass}.`);
|
|
@@ -175,6 +160,7 @@ function rejectTelegramForeignUpdateSettlement(settlement, operation, source) {
|
|
|
175
160
|
}
|
|
176
161
|
export const TELEGRAM_INTERNAL_AGENT_MESSAGE = Symbol("telegram.internalAgentMessage");
|
|
177
162
|
const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol("telegram.update-admission.binding");
|
|
163
|
+
const TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT = 20;
|
|
178
164
|
function bindTelegramUpdateAdmissionCarrier(value, binding) {
|
|
179
165
|
if (!value || typeof value !== "object")
|
|
180
166
|
return value;
|
|
@@ -195,13 +181,14 @@ function getTelegramUpdateAdmissionBinding(value) {
|
|
|
195
181
|
? binding
|
|
196
182
|
: undefined;
|
|
197
183
|
}
|
|
198
|
-
export function bindTelegramUpdateAdmissionSource(update, report) {
|
|
184
|
+
export function bindTelegramUpdateAdmissionSource(update, report, controls) {
|
|
199
185
|
if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
|
|
200
186
|
throw new TelegramUpdateAdmissionOutcomeError("Telegram update admission requires a safe update_id.");
|
|
201
187
|
}
|
|
202
188
|
const binding = {
|
|
203
189
|
sourceUpdateId: update.update_id,
|
|
204
190
|
report,
|
|
191
|
+
...controls,
|
|
205
192
|
};
|
|
206
193
|
const callbackQuery = update.callback_query
|
|
207
194
|
? bindTelegramUpdateAdmissionCarrier({
|
|
@@ -232,6 +219,24 @@ export function bindTelegramUpdateAdmissionSource(update, report) {
|
|
|
232
219
|
: {}),
|
|
233
220
|
};
|
|
234
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Conservative read-only census: any retained entry naming this Thread, in any nested Telegram object or state,
|
|
224
|
+
* is unresolved custody. It cannot see updates still in transit before journal append.
|
|
225
|
+
*/
|
|
226
|
+
export function collectTelegramJournalThreadUpdateIds(entries, target) {
|
|
227
|
+
const matches = (value, depth) => {
|
|
228
|
+
if (depth > 8 || !value || typeof value !== "object")
|
|
229
|
+
return false;
|
|
230
|
+
const record = value;
|
|
231
|
+
if (record.message_thread_id === target.threadId) {
|
|
232
|
+
const chat = record.chat;
|
|
233
|
+
if (!chat || typeof chat !== "object" || chat.id === target.chatId)
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
return Object.values(record).some(child => matches(child, depth + 1));
|
|
237
|
+
};
|
|
238
|
+
return entries.filter(entry => matches(entry.update, 0)).map(entry => entry.updateId).sort((left, right) => left - right);
|
|
239
|
+
}
|
|
235
240
|
export function collectTelegramAdmissionSourceUpdateIds(values) {
|
|
236
241
|
const sourceUpdateIds = new Set();
|
|
237
242
|
for (const value of values) {
|
|
@@ -241,27 +246,122 @@ export function collectTelegramAdmissionSourceUpdateIds(values) {
|
|
|
241
246
|
}
|
|
242
247
|
return [...sourceUpdateIds].sort((left, right) => left - right);
|
|
243
248
|
}
|
|
249
|
+
/** Observe the original journal entry, never the mutable routed carrier, through current worker authority. */
|
|
250
|
+
export function inspectTelegramDeferredSource(value) {
|
|
251
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
252
|
+
if (execution?.isCurrent() !== true)
|
|
253
|
+
return undefined;
|
|
254
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectSource?.();
|
|
255
|
+
}
|
|
256
|
+
/** A route-owned pre-disposition acceptance publisher; wraps only this carrier, never the shared worker binding. */
|
|
257
|
+
export function bindTelegramUpdateCompletionAcceptance(value, publish) {
|
|
258
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
259
|
+
if (!binding || getTelegramUpdateExecutionFence(value)?.isCurrent() !== true) {
|
|
260
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance requires a live source carrier.");
|
|
261
|
+
}
|
|
262
|
+
let retained;
|
|
263
|
+
let queued = false;
|
|
264
|
+
const scoped = { ...binding, report(outcome) {
|
|
265
|
+
if (outcome.kind !== "complete") {
|
|
266
|
+
if (outcome.kind === "queued")
|
|
267
|
+
queued = true;
|
|
268
|
+
binding.report(outcome);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
272
|
+
// A command's trailing implicit completion cannot dispose receipt-owned or uncertain queued work.
|
|
273
|
+
if (queued) {
|
|
274
|
+
if (outcome.expectedSource)
|
|
275
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance cannot dispose a queued source.");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!retained) {
|
|
279
|
+
const published = validateTelegramUpdateAdmissionOutcome({ kind: "complete", expectedSource: publish() }, binding.sourceUpdateId, new Set([binding.sourceUpdateId]));
|
|
280
|
+
if (published.kind !== "complete" || !published.expectedSource?.completionSha256) {
|
|
281
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance lacks its scoped journal proof.");
|
|
282
|
+
}
|
|
283
|
+
retained = published.expectedSource;
|
|
284
|
+
}
|
|
285
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
286
|
+
if (outcome.expectedSource && (outcome.expectedSource.updateId !== retained.updateId ||
|
|
287
|
+
outcome.expectedSource.journalBindingKey !== retained.journalBindingKey || outcome.expectedSource.sourceSha256 !== retained.sourceSha256 ||
|
|
288
|
+
outcome.expectedSource.completionSha256 !== undefined && outcome.expectedSource.completionSha256 !== retained.completionSha256)) {
|
|
289
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance conflicts with its reported source.");
|
|
290
|
+
}
|
|
291
|
+
binding.report({ kind: "complete", expectedSource: { ...retained } });
|
|
292
|
+
} };
|
|
293
|
+
return carryTelegramUpdateExecutionFence(value, bindTelegramUpdateAdmissionCarrier(value, scoped));
|
|
294
|
+
}
|
|
244
295
|
/** Report source completion; true means reported, not a durable settlement acknowledgement. */
|
|
245
|
-
export function reportTelegramUpdateCompleted(value) {
|
|
296
|
+
export function reportTelegramUpdateCompleted(value, expectedSource) {
|
|
246
297
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
247
298
|
if (!binding)
|
|
248
299
|
return false;
|
|
249
300
|
const execution = getTelegramUpdateExecutionFence(value);
|
|
250
301
|
if (execution && !execution.isCurrent())
|
|
251
302
|
return false;
|
|
252
|
-
binding.report({ kind: "complete" });
|
|
303
|
+
binding.report(validateTelegramUpdateAdmissionOutcome({ kind: "complete", ...(expectedSource !== undefined ? { expectedSource } : {}) }, binding.sourceUpdateId, new Set([binding.sourceUpdateId])));
|
|
253
304
|
return true;
|
|
254
305
|
}
|
|
255
306
|
export function reportTelegramUpdateDeferred(value) {
|
|
256
307
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
257
308
|
if (!binding)
|
|
258
309
|
return false;
|
|
310
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
311
|
+
if (execution && !execution.isCurrent())
|
|
312
|
+
return false;
|
|
259
313
|
binding.report({ kind: "deferred" });
|
|
260
314
|
return true;
|
|
261
315
|
}
|
|
316
|
+
/** Exact source-bound cancellation; undefined means no eligible attempt was made. */
|
|
317
|
+
export function abandonTelegramDeferredUpdate(value, authority) {
|
|
318
|
+
return getTelegramUpdateAdmissionBinding(value)?.abandon?.(authority);
|
|
319
|
+
}
|
|
320
|
+
/** Capability for chooser publication; the eventual action still requires exact deferred authority. */
|
|
321
|
+
export function supportsTelegramDeferredAbandonment(value, journalBindingKey) {
|
|
322
|
+
return getTelegramUpdateAdmissionBinding(value)?.supportsAbandonment?.(journalBindingKey) === true;
|
|
323
|
+
}
|
|
324
|
+
/** Observe protected attempts through a current carrier; the UI still owns human authorization. */
|
|
325
|
+
export function inspectTelegramAbandoningUpdates(value, request) {
|
|
326
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectAbandoning?.(request);
|
|
327
|
+
}
|
|
328
|
+
/** Historical inspection is not human confirmation or permission to stop delivery. */
|
|
329
|
+
export function inspectTelegramHistoricalInputs(value, request) {
|
|
330
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectHistorical?.(request);
|
|
331
|
+
}
|
|
332
|
+
export function isTelegramHistoricalInput(value, matchesOriginal) {
|
|
333
|
+
return getTelegramUpdateAdmissionBinding(value)?.isHistorical?.(matchesOriginal) === true;
|
|
334
|
+
}
|
|
335
|
+
/** Routing's last-boundary hold when ownership changed after early classification. */
|
|
336
|
+
export function reportTelegramHistoricalRoutingReview(value) {
|
|
337
|
+
if (!isTelegramHistoricalInput(value) || getTelegramUpdateExecutionFence(value)?.isCurrent() !== true)
|
|
338
|
+
return false;
|
|
339
|
+
getTelegramUpdateAdmissionBinding(value).report({ kind: "deferred", routingReview: true });
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
/** Arm only a positively published chooser; the journal owns the immutable hour deadline. */
|
|
343
|
+
export function armTelegramRoutingInputs(values, operatorUserId) {
|
|
344
|
+
const first = values[0], execution = getTelegramUpdateExecutionFence(first);
|
|
345
|
+
if (!execution || values.some(value => getTelegramUpdateExecutionFence(value)?.signal !== execution.signal))
|
|
346
|
+
return undefined;
|
|
347
|
+
for (const value of values)
|
|
348
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
349
|
+
const ids = collectTelegramAdmissionSourceUpdateIds(values);
|
|
350
|
+
if (!ids.length)
|
|
351
|
+
return undefined;
|
|
352
|
+
return getTelegramUpdateAdmissionBinding(first)?.armRoutingInput?.(operatorUserId, ids);
|
|
353
|
+
}
|
|
354
|
+
/** Reserve source execution; an actual choice must also freeze its durable TTL before any effect. */
|
|
355
|
+
export function acquireTelegramUpdateRouting(value, select = false, sourceUpdateIds) {
|
|
356
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
357
|
+
return getTelegramUpdateAdmissionBinding(value)?.acquireRouting?.(select, sourceUpdateIds) ?? (() => { });
|
|
358
|
+
}
|
|
262
359
|
export function reportTelegramQueueAdmission(values, receipts) {
|
|
263
360
|
const bindings = new Map();
|
|
264
361
|
for (const value of values) {
|
|
362
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
363
|
+
if (execution && !execution.isCurrent())
|
|
364
|
+
return false;
|
|
265
365
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
266
366
|
if (!binding)
|
|
267
367
|
continue;
|
|
@@ -427,7 +527,13 @@ function getForeignTelegramMessageOwnership(target, deps) {
|
|
|
427
527
|
: undefined;
|
|
428
528
|
}
|
|
429
529
|
function getForeignTelegramCallbackOwnership(query, deps) {
|
|
430
|
-
const
|
|
530
|
+
const messageTarget = getTelegramCallbackMessageTarget(query);
|
|
531
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
532
|
+
const knownOwner = messageTarget && deps.getMessageOwnership?.(messageTarget.chatId, messageTarget.messageId);
|
|
533
|
+
// A locally published chooser stays local when Restore moves its Thread to a follower.
|
|
534
|
+
if (currentInstanceId && knownOwner?.instanceId === currentInstanceId)
|
|
535
|
+
return undefined;
|
|
536
|
+
const messageOwnership = getForeignTelegramMessageOwnership(messageTarget, deps);
|
|
431
537
|
const targetOwnership = getForeignTelegramTargetOwnership(query.message ? getTelegramMessageTarget(query.message) : undefined, deps);
|
|
432
538
|
if (!messageOwnership)
|
|
433
539
|
return targetOwnership;
|
|
@@ -727,7 +833,11 @@ export async function executeTelegramUpdatePlan(plan, deps) {
|
|
|
727
833
|
deps.handleUnboundTelegramTopicMessage) {
|
|
728
834
|
assertExecutionCurrent();
|
|
729
835
|
await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
|
|
730
|
-
|
|
836
|
+
// A terminal historical hold intentionally suspends this exact carrier.
|
|
837
|
+
// Do not turn its acknowledged deferral into a retryable execution failure.
|
|
838
|
+
if (getTelegramUpdateExecutionFence(plan.message) !== deps.execution ||
|
|
839
|
+
getTelegramUpdateAdmissionBinding(plan.message)?.isHistoricalReviewHeld?.() !== true)
|
|
840
|
+
assertExecutionCurrent();
|
|
731
841
|
return;
|
|
732
842
|
}
|
|
733
843
|
if (plan.kind === "edited-message") {
|
|
@@ -746,10 +856,28 @@ export async function executeTelegramUpdatePlan(plan, deps) {
|
|
|
746
856
|
}
|
|
747
857
|
}
|
|
748
858
|
// --- Durable update worker ---
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
859
|
+
const TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS = 1_000;
|
|
860
|
+
const TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS = 60_000;
|
|
861
|
+
const TELEGRAM_UPDATE_WORKER_BATCH_SIZE = 64;
|
|
862
|
+
function normalizeTelegramQueueSourceCompletions(value, sourceUpdateIds, journalBindingKey, full = true) {
|
|
863
|
+
if (!Array.isArray(value) || (full ? value.length !== sourceUpdateIds.size : value.length > sourceUpdateIds.size) || value.length === 0)
|
|
864
|
+
throw new Error("Telegram scoped queue completion requires full source coverage.");
|
|
865
|
+
const seen = new Set(), scopes = new Set();
|
|
866
|
+
return value.map(source => {
|
|
867
|
+
if (!Number.isSafeInteger(source?.updateId) || !sourceUpdateIds.has(source.updateId) || seen.has(source.updateId)) {
|
|
868
|
+
throw new Error("Telegram scoped queue completion has invalid source membership.");
|
|
869
|
+
}
|
|
870
|
+
const normalized = validateTelegramUpdateAdmissionOutcome({ kind: "complete", expectedSource: source }, source.updateId, sourceUpdateIds);
|
|
871
|
+
if (normalized.kind !== "complete" || !normalized.expectedSource?.completionSha256 ||
|
|
872
|
+
normalized.expectedSource.journalBindingKey !== journalBindingKey || scopes.has(normalized.expectedSource.completionSha256)) {
|
|
873
|
+
throw new Error("Telegram scoped queue completion has invalid source scope.");
|
|
874
|
+
}
|
|
875
|
+
seen.add(source.updateId);
|
|
876
|
+
scopes.add(normalized.expectedSource.completionSha256);
|
|
877
|
+
return { ...normalized.expectedSource, completionSha256: normalized.expectedSource.completionSha256 };
|
|
878
|
+
}).sort((a, b) => a.updateId - b.updateId);
|
|
879
|
+
}
|
|
880
|
+
class TelegramUpdateAdmissionOutcomeError extends Error {
|
|
753
881
|
constructor(message) {
|
|
754
882
|
super(message);
|
|
755
883
|
this.name = "TelegramUpdateAdmissionOutcomeError";
|
|
@@ -759,20 +887,29 @@ const TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED = Symbol("telegram.update-worker.
|
|
|
759
887
|
function getTelegramUpdateWorkerStateSnapshot(state) {
|
|
760
888
|
return { ...state };
|
|
761
889
|
}
|
|
762
|
-
function isTelegramUpdateAdmissionRecord(value) {
|
|
763
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
764
|
-
}
|
|
765
|
-
function isTelegramUpdateAdmissionString(value) {
|
|
766
|
-
return typeof value === "string" && value.length > 0;
|
|
767
|
-
}
|
|
768
890
|
function validateTelegramUpdateAdmissionOutcome(value, currentUpdateId, claimableUpdateIds) {
|
|
769
891
|
if (!isTelegramUpdateAdmissionRecord(value)) {
|
|
770
892
|
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${currentUpdateId} returned no admission outcome.`);
|
|
771
893
|
}
|
|
772
|
-
if (value.kind === "complete")
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
894
|
+
if (value.kind === "complete") {
|
|
895
|
+
if (value.expectedSource === undefined)
|
|
896
|
+
return { kind: "complete" };
|
|
897
|
+
const source = value.expectedSource;
|
|
898
|
+
if (!isTelegramUpdateAdmissionRecord(source) || Object.keys(source).some(key => !["journalBindingKey", "updateId", "sourceSha256", "completionSha256"].includes(key)) ||
|
|
899
|
+
source.updateId !== currentUpdateId || !isTelegramUpdateAdmissionString(source.journalBindingKey) ||
|
|
900
|
+
typeof source.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sourceSha256) ||
|
|
901
|
+
source.completionSha256 !== undefined && (typeof source.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.completionSha256))) {
|
|
902
|
+
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${currentUpdateId} returned an invalid completion source.`);
|
|
903
|
+
}
|
|
904
|
+
return { kind: "complete", expectedSource: { journalBindingKey: source.journalBindingKey,
|
|
905
|
+
updateId: currentUpdateId, sourceSha256: source.sourceSha256,
|
|
906
|
+
...(source.completionSha256 !== undefined ? { completionSha256: source.completionSha256 } : {}) } };
|
|
907
|
+
}
|
|
908
|
+
if (value.kind === "deferred") {
|
|
909
|
+
if (value.routingReview !== undefined && value.routingReview !== true)
|
|
910
|
+
throw new TelegramUpdateAdmissionOutcomeError("Invalid historical review outcome.");
|
|
911
|
+
return { kind: "deferred", ...(value.routingReview === true ? { routingReview: true } : {}) };
|
|
912
|
+
}
|
|
776
913
|
if (value.kind === "queued") {
|
|
777
914
|
if ((value.queueKind !== "prompt" && value.queueKind !== "control") ||
|
|
778
915
|
!isTelegramUpdateAdmissionString(value.receiptId) ||
|
|
@@ -871,12 +1008,23 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
871
1008
|
throw new Error("Telegram update retry scheduling requires matching schedule and cancel ports.");
|
|
872
1009
|
}
|
|
873
1010
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
1011
|
+
const onHeldSourcesPrepared = deps.onHeldSourcesPrepared;
|
|
1012
|
+
const spendHistorical = deps.spendHistoricalInput === true;
|
|
1013
|
+
const routingJournal = deps.journal.routingInputs;
|
|
1014
|
+
const armRouting = routingJournal?.arm.bind(routingJournal);
|
|
1015
|
+
const selectRouting = routingJournal?.select.bind(routingJournal);
|
|
1016
|
+
const expireRouting = routingJournal?.expire.bind(routingJournal);
|
|
1017
|
+
const expireRoutingObserver = deps.expireRoutingInput;
|
|
874
1018
|
const batchSize = deps.batchSize ?? TELEGRAM_UPDATE_WORKER_BATCH_SIZE;
|
|
875
1019
|
if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
|
|
876
1020
|
throw new Error("Telegram update worker batch size must be positive.");
|
|
877
1021
|
}
|
|
878
1022
|
const yieldToEventLoop = deps.yieldToEventLoop ??
|
|
879
1023
|
(() => new Promise((resolve) => setTimeout(resolve, 0)));
|
|
1024
|
+
const completeQueuedExact = deps.journal.completeQueuedExact?.bind(deps.journal);
|
|
1025
|
+
const completeExact = deps.journal.removeCompletedExact?.bind(deps.journal);
|
|
1026
|
+
const inspectCompletion = deps.journal.inspectSourceCompletion?.bind(deps.journal);
|
|
1027
|
+
const observeQueuedCompletion = deps.onQueueReceiptCompleted;
|
|
880
1028
|
const fallbackQueueOwnerInstanceId = `worker-${randomUUID()}`;
|
|
881
1029
|
const fallbackQueueOwnerProcessId = process.pid > 0 ? process.pid : 1;
|
|
882
1030
|
const createAbortController = deps.createAbortController ?? (() => new AbortController());
|
|
@@ -890,6 +1038,13 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
890
1038
|
processBirthId: `${fallbackQueueOwnerProcessId}:${fallbackQueueOwnerInstanceId}`,
|
|
891
1039
|
sessionGeneration: generation,
|
|
892
1040
|
});
|
|
1041
|
+
const isQueueOwnerIdentityCurrent = (expected) => {
|
|
1042
|
+
if (!deps.getQueueOwnerIdentity)
|
|
1043
|
+
return true;
|
|
1044
|
+
const live = deps.getQueueOwnerIdentity(expected.ctx), captured = expected.queueOwnerIdentity;
|
|
1045
|
+
return !!live && live.instanceId === captured.instanceId && live.processId === captured.processId &&
|
|
1046
|
+
live.processBirthId === captured.processBirthId && live.sessionGeneration === captured.sessionGeneration;
|
|
1047
|
+
};
|
|
893
1048
|
const state = {
|
|
894
1049
|
phase: "stopped",
|
|
895
1050
|
generation: 0,
|
|
@@ -903,8 +1058,11 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
903
1058
|
unsettledExecutionCount: 0,
|
|
904
1059
|
};
|
|
905
1060
|
const claims = new Map();
|
|
1061
|
+
const deferredSources = new Map();
|
|
906
1062
|
const unsettledExecutionsByUpdateId = new Map();
|
|
907
1063
|
const committedQueueReceipts = new Map();
|
|
1064
|
+
const requiredScopedQueueReceipts = new Set();
|
|
1065
|
+
const scopedQueueCompletionAttempts = new Map();
|
|
908
1066
|
const unsettledExecutions = new Set();
|
|
909
1067
|
let owner;
|
|
910
1068
|
let drainPromise;
|
|
@@ -914,6 +1072,8 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
914
1072
|
let retryTimer;
|
|
915
1073
|
let retryTimerAtMs;
|
|
916
1074
|
let retryTimerToken;
|
|
1075
|
+
let routingTimer;
|
|
1076
|
+
let routingTimerToken;
|
|
917
1077
|
let launchDrain = () => { };
|
|
918
1078
|
const recordRuntimeEvent = (error, details) => {
|
|
919
1079
|
try {
|
|
@@ -934,14 +1094,28 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
934
1094
|
const updateClaimCounts = () => {
|
|
935
1095
|
let deferredClaimCount = 0;
|
|
936
1096
|
let queuedClaimCount = 0;
|
|
1097
|
+
let abandoningClaimCount = 0;
|
|
1098
|
+
let historicalClaimCount = 0;
|
|
937
1099
|
for (const claim of claims.values()) {
|
|
938
1100
|
if (claim === "queued")
|
|
939
1101
|
queuedClaimCount += 1;
|
|
940
1102
|
else
|
|
941
1103
|
deferredClaimCount += 1;
|
|
1104
|
+
if (claim === "abandoning")
|
|
1105
|
+
abandoningClaimCount += 1;
|
|
1106
|
+
if (claim === "historical" || claim === "retained")
|
|
1107
|
+
historicalClaimCount += 1;
|
|
942
1108
|
}
|
|
943
1109
|
state.deferredClaimCount = deferredClaimCount;
|
|
944
1110
|
state.queuedClaimCount = queuedClaimCount;
|
|
1111
|
+
if (abandoningClaimCount > 0)
|
|
1112
|
+
state.abandoningClaimCount = abandoningClaimCount;
|
|
1113
|
+
else
|
|
1114
|
+
delete state.abandoningClaimCount;
|
|
1115
|
+
if (historicalClaimCount > 0)
|
|
1116
|
+
state.historicalClaimCount = historicalClaimCount;
|
|
1117
|
+
else
|
|
1118
|
+
delete state.historicalClaimCount;
|
|
945
1119
|
};
|
|
946
1120
|
const releaseDeferredClaims = (updateIds) => {
|
|
947
1121
|
let changed = false;
|
|
@@ -949,6 +1123,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
949
1123
|
if (claims.get(updateId) !== "deferred")
|
|
950
1124
|
continue;
|
|
951
1125
|
claims.delete(updateId);
|
|
1126
|
+
deferredSources.delete(updateId);
|
|
952
1127
|
changed = true;
|
|
953
1128
|
}
|
|
954
1129
|
if (!changed)
|
|
@@ -992,7 +1167,13 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
992
1167
|
? { journalBindingKey: deps.getJournalBindingKey() }
|
|
993
1168
|
: {}),
|
|
994
1169
|
});
|
|
995
|
-
const
|
|
1170
|
+
const inspectQueueReceipt = deps.journal.isQueueReceiptCurrent?.bind(deps.journal);
|
|
1171
|
+
const inspectQueuedSources = deps.journal.inspectQueuedReceipt?.bind(deps.journal);
|
|
1172
|
+
const prepareQueueReceipt = deps.beforeQueueReceiptPublished;
|
|
1173
|
+
const pendingQueuePublications = new Map();
|
|
1174
|
+
const publishCommittedQueueReceipt = async (receipt, queueOwner, expectedOwner) => {
|
|
1175
|
+
const ctx = expectedOwner.ctx;
|
|
1176
|
+
let sourceCompletions;
|
|
996
1177
|
const normalized = bindQueueReceiptToJournal(receipt);
|
|
997
1178
|
const existing = committedQueueReceipts.get(receipt.receiptId);
|
|
998
1179
|
if (existing) {
|
|
@@ -1002,28 +1183,90 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1002
1183
|
}
|
|
1003
1184
|
return false;
|
|
1004
1185
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1186
|
+
if (prepareQueueReceipt) {
|
|
1187
|
+
const pending = pendingQueuePublications.get(normalized.receiptId);
|
|
1188
|
+
if (pending) {
|
|
1189
|
+
if (pending.owner !== expectedOwner || !areTelegramQueueAdmissionReceiptsEqual(pending.receipt, normalized) ||
|
|
1190
|
+
!areTelegramUpdateJournalQueueOwnersEqual(pending.queueOwner, queueOwner)) {
|
|
1191
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication has conflicting in-flight authority.");
|
|
1192
|
+
}
|
|
1193
|
+
await pending.task;
|
|
1194
|
+
return false;
|
|
1195
|
+
}
|
|
1196
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
1197
|
+
const current = () => owner === expectedOwner && !expectedOwner.controller.signal.aborted && deps.hasAuthority(ctx) && deps.isContextCurrent?.(ctx) !== false &&
|
|
1198
|
+
isQueueOwnerIdentityCurrent(expectedOwner) && deps.getJournalBindingKey?.() === journalBindingKey;
|
|
1199
|
+
if (!journalBindingKey || !current() || !inspectQueueReceipt ||
|
|
1200
|
+
inspectQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }) !== true || !current()) {
|
|
1201
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication requires exact current receipt inspection.");
|
|
1202
|
+
}
|
|
1203
|
+
const task = Promise.resolve().then(async () => {
|
|
1204
|
+
if (!current())
|
|
1205
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication authority ended before acceptance.");
|
|
1206
|
+
const prepared = await prepareQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }, ctx, current);
|
|
1207
|
+
if (prepared !== undefined) {
|
|
1208
|
+
sourceCompletions = normalizeTelegramQueueSourceCompletions(prepared, new Set(normalized.sourceUpdateIds), journalBindingKey, false);
|
|
1209
|
+
if (!completeQueuedExact || !inspectCompletion)
|
|
1210
|
+
throw new Error("Telegram scoped queue terminal capabilities are unavailable.");
|
|
1211
|
+
if (sourceCompletions.length !== normalized.sourceUpdateIds.length) {
|
|
1212
|
+
if (!current() || !inspectQueuedSources)
|
|
1213
|
+
throw new Error("Telegram partial queue scopes require strict current whole-receipt origin inspection.");
|
|
1214
|
+
const expected = { queueKind: normalized.queueKind, receiptId: normalized.receiptId,
|
|
1215
|
+
sourceUpdateIds: [...normalized.sourceUpdateIds], queueOwner: { ...queueOwner } };
|
|
1216
|
+
const proof = inspectQueuedSources(structuredClone(expected));
|
|
1217
|
+
if (!current() || !proof || !isDeepStrictEqual(proof.receipt, expected) ||
|
|
1218
|
+
!Array.isArray(proof.sources) || proof.sources.length !== expected.sourceUpdateIds.length ||
|
|
1219
|
+
proof.sources.some((source, index) => source.updateId !== expected.sourceUpdateIds[index] || typeof source.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sourceSha256)) ||
|
|
1220
|
+
sourceCompletions.some(scope => proof.sources.find(source => source.updateId === scope.updateId)?.sourceSha256 !== scope.sourceSha256)) {
|
|
1221
|
+
throw new Error("Telegram partial queue scope origin was not confirmed under whole-receipt authority.");
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
for (const { journalBindingKey: _binding, ...completion } of sourceCompletions) {
|
|
1225
|
+
if (!current() || inspectCompletion({ ...completion }) !== undefined || !current()) {
|
|
1226
|
+
throw new Error("Telegram queued source completion authority is contradictory.");
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (!current() || inspectQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }) !== true || !current()) {
|
|
1231
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication authority changed after acceptance.");
|
|
1232
|
+
}
|
|
1233
|
+
publishReady();
|
|
1234
|
+
}).finally(() => { if (pendingQueuePublications.get(normalized.receiptId)?.task === task)
|
|
1235
|
+
pendingQueuePublications.delete(normalized.receiptId); });
|
|
1236
|
+
pendingQueuePublications.set(normalized.receiptId, { receipt: normalizeQueueReceipt(normalized), queueOwner: { ...queueOwner }, owner: expectedOwner, task });
|
|
1237
|
+
await task;
|
|
1238
|
+
return true;
|
|
1011
1239
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1240
|
+
publishReady();
|
|
1241
|
+
return true;
|
|
1242
|
+
function publishReady() {
|
|
1243
|
+
committedQueueReceipts.set(receipt.receiptId, {
|
|
1244
|
+
receipt: normalized,
|
|
1245
|
+
queueOwner: { ...queueOwner },
|
|
1246
|
+
...(sourceCompletions ? { sourceCompletions: sourceCompletions.map(value => ({ ...value })) } : {}),
|
|
1016
1247
|
});
|
|
1248
|
+
if (sourceCompletions)
|
|
1249
|
+
requiredScopedQueueReceipts.add(receipt.receiptId);
|
|
1250
|
+
try {
|
|
1251
|
+
deps.onQueueReceiptCommitted?.(normalizeQueueReceipt(normalized), ctx);
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
recordRuntimeEvent(error, {
|
|
1255
|
+
phase: "queue-receipt-observer",
|
|
1256
|
+
receiptId: normalized.receiptId,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1017
1259
|
}
|
|
1018
|
-
return true;
|
|
1019
1260
|
};
|
|
1020
1261
|
const getCurrentQueueReceipt = (receipt) => {
|
|
1262
|
+
if (scopedQueueCompletionAttempts.has(receipt.receiptId))
|
|
1263
|
+
return undefined;
|
|
1021
1264
|
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
1022
1265
|
if (!committed || !areTelegramQueueAdmissionReceiptsEqual(committed.receipt, normalizeQueueReceipt(receipt)))
|
|
1023
1266
|
return undefined;
|
|
1024
1267
|
try {
|
|
1025
|
-
if (
|
|
1026
|
-
|
|
1268
|
+
if (inspectQueueReceipt &&
|
|
1269
|
+
inspectQueueReceipt(normalizeQueueReceipt(committed.receipt), { ...committed.queueOwner }) !== true) {
|
|
1027
1270
|
return undefined;
|
|
1028
1271
|
}
|
|
1029
1272
|
}
|
|
@@ -1066,7 +1309,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1066
1309
|
launchDrain();
|
|
1067
1310
|
}, Math.max(0, nextRetryAtMs - getNowMs()));
|
|
1068
1311
|
};
|
|
1069
|
-
const refreshJournalState = (snapshot, expectedOwner) => {
|
|
1312
|
+
const refreshJournalState = async (snapshot, expectedOwner) => {
|
|
1070
1313
|
// Validate the whole snapshot before reconstructing any queue authority.
|
|
1071
1314
|
if ((snapshot.version !== TELEGRAM_UPDATE_JOURNAL_VERSION &&
|
|
1072
1315
|
snapshot.version !== TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION &&
|
|
@@ -1095,7 +1338,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1095
1338
|
oldestAdmittedAtMs === undefined
|
|
1096
1339
|
? entry.admittedAtMs
|
|
1097
1340
|
: Math.min(oldestAdmittedAtMs, entry.admittedAtMs);
|
|
1098
|
-
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined) {
|
|
1341
|
+
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined && !claims.has(entry.updateId)) {
|
|
1099
1342
|
scheduledRetryAtMs =
|
|
1100
1343
|
scheduledRetryAtMs === undefined
|
|
1101
1344
|
? entry.nextRetryAtMs
|
|
@@ -1156,17 +1399,22 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1156
1399
|
}
|
|
1157
1400
|
}
|
|
1158
1401
|
for (const [updateId, claim] of claims) {
|
|
1159
|
-
if (!availableUpdateIds.has(updateId) ||
|
|
1402
|
+
if ((claim !== "abandoning" && !availableUpdateIds.has(updateId)) ||
|
|
1160
1403
|
(claim === "queued" && !locallyOwnedQueuedUpdateIds.has(updateId))) {
|
|
1161
1404
|
claims.delete(updateId);
|
|
1162
1405
|
}
|
|
1163
1406
|
}
|
|
1407
|
+
for (const updateId of deferredSources.keys()) {
|
|
1408
|
+
const claim = claims.get(updateId);
|
|
1409
|
+
if (claim !== "deferred" && claim !== "abandoning" && claim !== "historical" && claim !== "retained")
|
|
1410
|
+
deferredSources.delete(updateId);
|
|
1411
|
+
}
|
|
1164
1412
|
for (const [receiptId, receipt] of queuedReceiptEntries) {
|
|
1165
|
-
publishCommittedQueueReceipt({
|
|
1413
|
+
await publishCommittedQueueReceipt({
|
|
1166
1414
|
queueKind: receipt.queueKind,
|
|
1167
1415
|
receiptId,
|
|
1168
1416
|
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
1169
|
-
}, receipt.queueOwner, expectedOwner
|
|
1417
|
+
}, receipt.queueOwner, expectedOwner);
|
|
1170
1418
|
}
|
|
1171
1419
|
for (const [receiptId] of committedQueueReceipts) {
|
|
1172
1420
|
if (!queuedReceiptEntries.has(receiptId)) {
|
|
@@ -1307,13 +1555,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1307
1555
|
for (const sourceUpdateId of normalized.sourceUpdateIds) {
|
|
1308
1556
|
claims.set(sourceUpdateId, "queued");
|
|
1309
1557
|
}
|
|
1310
|
-
|
|
1311
|
-
publishCommittedQueueReceipt(normalized, queueOwner, expectedOwner.ctx);
|
|
1312
|
-
}
|
|
1313
|
-
catch (error) {
|
|
1314
|
-
return blockWithFailure("invalid-outcome", "queue-receipt-publish", error, currentUpdateId);
|
|
1315
|
-
}
|
|
1316
|
-
return "committed";
|
|
1558
|
+
return publishCommittedQueueReceipt(normalized, queueOwner, expectedOwner).then(() => "committed", error => blockWithFailure("invalid-outcome", "queue-receipt-publish", error, currentUpdateId));
|
|
1317
1559
|
};
|
|
1318
1560
|
const persistExecutionFailure = (expectedOwner, entry, error) => {
|
|
1319
1561
|
const authorityResult = checkAuthority(expectedOwner, entry.updateId);
|
|
@@ -1372,14 +1614,52 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1372
1614
|
transition(disposition, entry.updateId);
|
|
1373
1615
|
return disposition;
|
|
1374
1616
|
};
|
|
1375
|
-
const commitCompletedBatch = (expectedOwner,
|
|
1617
|
+
const commitCompletedBatch = (expectedOwner, completions) => {
|
|
1618
|
+
const updateIds = completions.map(value => value.updateId);
|
|
1376
1619
|
if (updateIds.length === 0)
|
|
1377
1620
|
return undefined;
|
|
1378
1621
|
const commitAuthority = checkAuthority(expectedOwner, updateIds[updateIds.length - 1]);
|
|
1379
1622
|
if (commitAuthority)
|
|
1380
1623
|
return commitAuthority;
|
|
1624
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
1381
1625
|
try {
|
|
1382
|
-
const
|
|
1626
|
+
const expectedSources = completions.flatMap(value => value.expectedSource ? [value.expectedSource] : []);
|
|
1627
|
+
if (expectedSources.some(value => value.journalBindingKey !== journalBindingKey) ||
|
|
1628
|
+
(expectedSources.length > 0 && (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner)))) {
|
|
1629
|
+
throw new Error("Telegram guarded completion source authority changed.");
|
|
1630
|
+
}
|
|
1631
|
+
const scoped = expectedSources.flatMap(({ updateId, sourceSha256, completionSha256 }) => completionSha256 !== undefined
|
|
1632
|
+
? [{ updateId, sourceSha256, completionSha256 }] : []).sort((a, b) => a.updateId - b.updateId);
|
|
1633
|
+
if (scoped.length > 0 && !inspectCompletion)
|
|
1634
|
+
throw new Error("Telegram source completion inspection is unavailable.");
|
|
1635
|
+
let removed;
|
|
1636
|
+
if (expectedSources.length > 0) {
|
|
1637
|
+
if (!completeExact)
|
|
1638
|
+
throw new Error("Telegram exact source completion is unavailable.");
|
|
1639
|
+
removed = completeExact(updateIds, expectedSources.map(({ updateId, sourceSha256 }) => ({ updateId, sourceSha256 })), scoped.length > 0 ? scoped.map(completion => ({ ...completion })) : undefined);
|
|
1640
|
+
}
|
|
1641
|
+
else
|
|
1642
|
+
removed = deps.journal.removeCompleted(updateIds);
|
|
1643
|
+
if (scoped.length > 0) {
|
|
1644
|
+
const afterCommit = checkAuthority(expectedOwner, updateIds.at(-1));
|
|
1645
|
+
if (afterCommit)
|
|
1646
|
+
return afterCommit;
|
|
1647
|
+
if (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
1648
|
+
deps.getJournalBindingKey?.() !== journalBindingKey)
|
|
1649
|
+
throw new Error("Telegram source completion ACK authority changed.");
|
|
1650
|
+
if (!isDeepStrictEqual(removed.sourceCompletions, scoped))
|
|
1651
|
+
throw new Error("Telegram source completion ACK was not confirmed.");
|
|
1652
|
+
for (const completion of scoped) {
|
|
1653
|
+
if (!isDeepStrictEqual(inspectCompletion({ ...completion }), completion))
|
|
1654
|
+
throw new Error("Telegram source completion ACK was not retained.");
|
|
1655
|
+
const afterRead = checkAuthority(expectedOwner, completion.updateId);
|
|
1656
|
+
if (afterRead)
|
|
1657
|
+
return afterRead;
|
|
1658
|
+
if (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
1659
|
+
deps.getJournalBindingKey?.() !== journalBindingKey)
|
|
1660
|
+
throw new Error("Telegram source completion ACK authority changed.");
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1383
1663
|
const removedIds = new Set(removed.removedUpdateIds);
|
|
1384
1664
|
if (updateIds.some((updateId) => !removedIds.has(updateId))) {
|
|
1385
1665
|
throw new Error("Telegram update batch changed before completion commit.");
|
|
@@ -1389,12 +1669,16 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1389
1669
|
return blockWithFailure("journal-write", "completion-commit", error, updateIds[updateIds.length - 1]);
|
|
1390
1670
|
}
|
|
1391
1671
|
const completedAtMs = getNowMs();
|
|
1392
|
-
for (const updateId of
|
|
1672
|
+
for (const { updateId, spent } of completions) {
|
|
1393
1673
|
claims.delete(updateId);
|
|
1674
|
+
deferredSources.delete(updateId);
|
|
1675
|
+
// Spending is disposition, not completion: no task, cleanup or session observer runs.
|
|
1676
|
+
if (spent)
|
|
1677
|
+
continue;
|
|
1394
1678
|
state.lastCompletedUpdateId = updateId;
|
|
1395
1679
|
state.lastCompletedAtMs = completedAtMs;
|
|
1396
1680
|
try {
|
|
1397
|
-
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx);
|
|
1681
|
+
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx, journalBindingKey);
|
|
1398
1682
|
}
|
|
1399
1683
|
catch (error) {
|
|
1400
1684
|
recordRuntimeEvent(error, {
|
|
@@ -1405,6 +1689,122 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1405
1689
|
}
|
|
1406
1690
|
return undefined;
|
|
1407
1691
|
};
|
|
1692
|
+
const clearRoutingTimer = () => {
|
|
1693
|
+
if (routingTimer !== undefined)
|
|
1694
|
+
cancelRetry(routingTimer);
|
|
1695
|
+
routingTimer = undefined;
|
|
1696
|
+
routingTimerToken = undefined;
|
|
1697
|
+
};
|
|
1698
|
+
const reconcileRoutingInputs = async (snapshot, expectedOwner) => {
|
|
1699
|
+
clearRoutingTimer();
|
|
1700
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) || !expireRouting || !expireRoutingObserver)
|
|
1701
|
+
return false;
|
|
1702
|
+
let nextAtMs, changed = false;
|
|
1703
|
+
const binding = deps.getJournalBindingKey?.(), snapshotIds = new Set(snapshot.entries.map(entry => entry.updateId));
|
|
1704
|
+
// Lost expiry ACKs reconcile the exact held original against its body-free discard tombstone.
|
|
1705
|
+
const candidates = [...snapshot.entries, ...[...deferredSources.values()]
|
|
1706
|
+
.filter(source => source.journalBindingKey === binding && !snapshotIds.has(source.entry.updateId))
|
|
1707
|
+
.map(source => source.entry)];
|
|
1708
|
+
for (const entry of candidates) {
|
|
1709
|
+
const lifetime = entry.routingInput;
|
|
1710
|
+
if (!lifetime || entry.state !== "pending" || !binding || claims.get(entry.updateId) === "queued" ||
|
|
1711
|
+
unsettledExecutionsByUpdateId.get(entry.updateId)?.size)
|
|
1712
|
+
continue;
|
|
1713
|
+
// Classify the first startup snapshot before constructing any historical expiry carrier.
|
|
1714
|
+
if (expectedOwner.startupUpdateIds?.has(entry.updateId) && !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput) {
|
|
1715
|
+
nextAtMs = Math.min(nextAtMs ?? Infinity, Math.max(getNowMs() + 1, lifetime.expiresAtMs));
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
if (getNowMs() < lifetime.expiresAtMs) {
|
|
1719
|
+
nextAtMs = Math.min(nextAtMs ?? Infinity, lifetime.expiresAtMs);
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
const existing = deferredSources.get(entry.updateId);
|
|
1723
|
+
if (existing && !isDeepStrictEqual(existing.entry, entry)) {
|
|
1724
|
+
// Lost clock/selection ACKs may leave only this immutable metadata behind its exact source; reconciliation grants expiry, never another dispatch.
|
|
1725
|
+
const previous = existing.entry.routingInput;
|
|
1726
|
+
if (!isDeepStrictEqual({ ...existing.entry, routingInput: lifetime }, entry) ||
|
|
1727
|
+
(previous && (!isDeepStrictEqual({ ...previous, phase: lifetime.phase }, lifetime) ||
|
|
1728
|
+
(previous.phase === "selected" && lifetime.phase !== "selected"))))
|
|
1729
|
+
continue;
|
|
1730
|
+
existing.entry = structuredClone(entry);
|
|
1731
|
+
}
|
|
1732
|
+
const source = existing ?? { entry: structuredClone(entry), journalBindingKey: binding, historical: true };
|
|
1733
|
+
deferredSources.set(entry.updateId, source);
|
|
1734
|
+
if (!claims.has(entry.updateId))
|
|
1735
|
+
claims.set(entry.updateId, "historical");
|
|
1736
|
+
const current = () => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
1737
|
+
deps.isContextCurrent?.(expectedOwner.ctx) !== false && deps.getJournalBindingKey?.() === binding &&
|
|
1738
|
+
deps.hasAuthority(expectedOwner.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) && deferredSources.get(entry.updateId) === source &&
|
|
1739
|
+
isDeepStrictEqual(source.entry, entry) && claims.get(entry.updateId) !== "queued";
|
|
1740
|
+
let committed;
|
|
1741
|
+
try {
|
|
1742
|
+
if (current())
|
|
1743
|
+
await expireRoutingObserver({ original: structuredClone(entry), journalBindingKey: binding, isCurrent: current,
|
|
1744
|
+
expire() {
|
|
1745
|
+
if (!current())
|
|
1746
|
+
return undefined;
|
|
1747
|
+
if (committed)
|
|
1748
|
+
return { ...committed, duplicate: true };
|
|
1749
|
+
claims.set(entry.updateId, "abandoning");
|
|
1750
|
+
committed = expireRouting({ entry: source.entry, journalBindingKey: binding, operatorUserId: lifetime.operatorUserId, isCurrent: current });
|
|
1751
|
+
// Source expiry is terminal before chooser/cleanup awaits; later failures must not retain a deferred/retry source.
|
|
1752
|
+
claims.delete(entry.updateId);
|
|
1753
|
+
deferredSources.delete(entry.updateId);
|
|
1754
|
+
state.journalEntryCount = committed.entryCount;
|
|
1755
|
+
state.journalSerializedBytes = committed.serializedBytes;
|
|
1756
|
+
updateClaimCounts();
|
|
1757
|
+
notifyStateChange();
|
|
1758
|
+
changed ||= !committed.duplicate;
|
|
1759
|
+
return committed;
|
|
1760
|
+
} }, expectedOwner.ctx, expectedOwner.controller.signal);
|
|
1761
|
+
}
|
|
1762
|
+
catch (error) {
|
|
1763
|
+
recordRuntimeEvent(error, { phase: "routing-input-expiry", updateId: entry.updateId });
|
|
1764
|
+
}
|
|
1765
|
+
if (!committed)
|
|
1766
|
+
nextAtMs = Math.min(nextAtMs ?? Infinity, getNowMs() + 60_000);
|
|
1767
|
+
}
|
|
1768
|
+
if (nextAtMs !== undefined && owner === expectedOwner && !expectedOwner.controller.signal.aborted) {
|
|
1769
|
+
const token = {};
|
|
1770
|
+
routingTimerToken = token;
|
|
1771
|
+
routingTimer = scheduleRetry(() => {
|
|
1772
|
+
if (routingTimerToken !== token || owner !== expectedOwner || expectedOwner.controller.signal.aborted)
|
|
1773
|
+
return;
|
|
1774
|
+
routingTimer = undefined;
|
|
1775
|
+
routingTimerToken = undefined;
|
|
1776
|
+
pendingSignal = true;
|
|
1777
|
+
launchDrain();
|
|
1778
|
+
}, Math.max(1, nextAtMs - getNowMs()));
|
|
1779
|
+
}
|
|
1780
|
+
return changed;
|
|
1781
|
+
};
|
|
1782
|
+
const mutateRoutingSource = (input, select) => {
|
|
1783
|
+
const expectedOwner = owner, ids = [...input.sourceUpdateIds];
|
|
1784
|
+
if (!ids.length || !ids.includes(input.updateId) || new Set(ids).size !== ids.length || ids.some(id => !Number.isSafeInteger(id) || id < 0))
|
|
1785
|
+
return undefined;
|
|
1786
|
+
const sources = ids.map(id => deferredSources.get(id)), binding = sources[0]?.journalBindingKey;
|
|
1787
|
+
const current = () => !!expectedOwner && owner === expectedOwner && expectedOwner.controller.signal === input.signal && !input.signal.aborted &&
|
|
1788
|
+
!!binding && sources.every((source, index) => !!source && deferredSources.get(ids[index]) === source && !source.historical &&
|
|
1789
|
+
source.journalBindingKey === binding && claims.get(ids[index]) !== "queued" && claims.get(ids[index]) !== "abandoning") &&
|
|
1790
|
+
deps.getJournalBindingKey?.() === binding && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
1791
|
+
deps.hasAuthority(expectedOwner.ctx) && isQueueOwnerIdentityCurrent(expectedOwner);
|
|
1792
|
+
if (!current() || !armRouting || !selectRouting || !expireRoutingObserver)
|
|
1793
|
+
return undefined;
|
|
1794
|
+
const authority = { journalBindingKey: binding, entries: sources.map(source => structuredClone(source.entry)), operatorUserId: input.operatorUserId, isCurrent: current };
|
|
1795
|
+
const result = select ? selectRouting(authority) : { issued: false, entries: armRouting({ ...authority, publishedAtMs: getNowMs() }) };
|
|
1796
|
+
if (result.entries.length !== ids.length || result.entries.some((entry, index) => entry.updateId !== ids[index] || !entry.routingInput) || !current()) {
|
|
1797
|
+
throw new Error("Telegram routing input lifetime ACK was not confirmed.");
|
|
1798
|
+
}
|
|
1799
|
+
const retained = deps.journal.read().entries;
|
|
1800
|
+
if (result.entries.some(entry => !isDeepStrictEqual(retained.find(value => value.updateId === entry.updateId), entry)) || !current()) {
|
|
1801
|
+
throw new Error("Telegram routing input lifetime ACK was not retained.");
|
|
1802
|
+
}
|
|
1803
|
+
result.entries.forEach((entry, index) => { sources[index].entry = structuredClone(entry); });
|
|
1804
|
+
pendingSignal = true;
|
|
1805
|
+
launchDrain();
|
|
1806
|
+
return { issued: result.issued, lifetime: { ...result.entries[0].routingInput } };
|
|
1807
|
+
};
|
|
1408
1808
|
const drain = async (expectedOwner) => {
|
|
1409
1809
|
while (owner === expectedOwner && !expectedOwner.controller.signal.aborted) {
|
|
1410
1810
|
const authorityResult = checkAuthority(expectedOwner);
|
|
@@ -1414,11 +1814,18 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1414
1814
|
let scheduledRetryAtMs;
|
|
1415
1815
|
try {
|
|
1416
1816
|
snapshot = deps.journal.read();
|
|
1417
|
-
scheduledRetryAtMs = refreshJournalState(snapshot, expectedOwner);
|
|
1817
|
+
scheduledRetryAtMs = await refreshJournalState(snapshot, expectedOwner);
|
|
1818
|
+
// One complete validated baseline per generation, before batching or execution.
|
|
1819
|
+
// Replayed v1 entries do not prove that a prior process never forwarded them.
|
|
1820
|
+
expectedOwner.startupUpdateIds ??= new Set(snapshot.entries.map(entry => entry.updateId));
|
|
1418
1821
|
}
|
|
1419
1822
|
catch (error) {
|
|
1420
1823
|
return blockWithFailure("journal-read", "journal-read", error);
|
|
1421
1824
|
}
|
|
1825
|
+
if (await reconcileRoutingInputs(snapshot, expectedOwner))
|
|
1826
|
+
continue;
|
|
1827
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted)
|
|
1828
|
+
return "aborted";
|
|
1422
1829
|
const nowMs = getNowMs();
|
|
1423
1830
|
const entries = [];
|
|
1424
1831
|
let hasMoreEntries = false;
|
|
@@ -1489,9 +1896,101 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1489
1896
|
break;
|
|
1490
1897
|
}
|
|
1491
1898
|
if (entry.preApprovalExcluded === true) {
|
|
1492
|
-
completedUpdateIds.push(entry.updateId);
|
|
1899
|
+
completedUpdateIds.push({ updateId: entry.updateId });
|
|
1493
1900
|
continue;
|
|
1494
1901
|
}
|
|
1902
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) && entry.state === "pending" && deps.journal.inspectPendingRetention) {
|
|
1903
|
+
const authorityResult = checkAuthority(expectedOwner, entry.updateId);
|
|
1904
|
+
if (authorityResult)
|
|
1905
|
+
return authorityResult;
|
|
1906
|
+
let retained;
|
|
1907
|
+
let unverifiable = false;
|
|
1908
|
+
try {
|
|
1909
|
+
retained = deps.journal.inspectPendingRetention(entry);
|
|
1910
|
+
}
|
|
1911
|
+
catch (error) {
|
|
1912
|
+
unverifiable = true;
|
|
1913
|
+
recordRuntimeEvent(error, { phase: "pending-retention-inspection", updateId: entry.updateId });
|
|
1914
|
+
}
|
|
1915
|
+
if (retained || unverifiable) {
|
|
1916
|
+
// A copy is protection evidence only. A fresh owner must explicitly retry
|
|
1917
|
+
// cancellation; neither startup nor a malformed copy may dispatch the source.
|
|
1918
|
+
claims.set(entry.updateId, "abandoning");
|
|
1919
|
+
const journalBindingKey = retained?.journalBindingKey ?? deps.getJournalBindingKey?.();
|
|
1920
|
+
if (journalBindingKey)
|
|
1921
|
+
deferredSources.set(entry.updateId, { entry, journalBindingKey });
|
|
1922
|
+
transition("deferred", entry.updateId);
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) && (entry.state === "pending" || entry.state === "retry-wait")) {
|
|
1927
|
+
const historical = expectedOwner.startupUpdateIds.has(entry.updateId);
|
|
1928
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
1929
|
+
// Classifiers and handler projections receive no mutable alias to CAS evidence.
|
|
1930
|
+
if (journalBindingKey)
|
|
1931
|
+
deferredSources.set(entry.updateId, { entry: structuredClone(entry), journalBindingKey,
|
|
1932
|
+
...(historical ? { historical: true } : {}) });
|
|
1933
|
+
const classifyPending = historical && entry.state === "pending" ? deps.shouldReviewHistoricalInput : deps.shouldHoldPendingInput;
|
|
1934
|
+
if (classifyPending) {
|
|
1935
|
+
const checkHistoricalAuthority = () => {
|
|
1936
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted)
|
|
1937
|
+
return "aborted";
|
|
1938
|
+
if (deps.getJournalBindingKey?.() !== journalBindingKey || deps.isContextCurrent?.(expectedOwner.ctx) === false) {
|
|
1939
|
+
transition("blocked", entry.updateId, "authority-lost");
|
|
1940
|
+
return "blocked";
|
|
1941
|
+
}
|
|
1942
|
+
return checkAuthority(expectedOwner, entry.updateId);
|
|
1943
|
+
};
|
|
1944
|
+
const beforeClassification = checkHistoricalAuthority();
|
|
1945
|
+
if (beforeClassification)
|
|
1946
|
+
return beforeClassification;
|
|
1947
|
+
let review;
|
|
1948
|
+
try {
|
|
1949
|
+
review = await classifyPending(structuredClone(entry), expectedOwner.ctx, expectedOwner.controller.signal);
|
|
1950
|
+
}
|
|
1951
|
+
catch (error) {
|
|
1952
|
+
const authorityResult = checkHistoricalAuthority();
|
|
1953
|
+
if (authorityResult)
|
|
1954
|
+
return authorityResult;
|
|
1955
|
+
return blockWithFailure("execution", "historical-review-classification", error, entry.updateId);
|
|
1956
|
+
}
|
|
1957
|
+
const authorityResult = checkHistoricalAuthority();
|
|
1958
|
+
if (authorityResult)
|
|
1959
|
+
return authorityResult;
|
|
1960
|
+
if (typeof review !== "boolean" && !(historical && entry.state === "pending" && review === "retain"))
|
|
1961
|
+
return blockWithFailure("execution", "historical-review-classification", new TelegramUpdateAdmissionOutcomeError("Historical classification must return a boolean or retain verdict."), entry.updateId);
|
|
1962
|
+
// Classification may await a domain read; accepted/changed source evidence wins.
|
|
1963
|
+
try {
|
|
1964
|
+
const current = deps.journal.read();
|
|
1965
|
+
if (current.version !== snapshot.version || JSON.stringify(current.entries.find(candidate => candidate.updateId === entry.updateId)) !== JSON.stringify(entry)) {
|
|
1966
|
+
snapshotInvalidated = true;
|
|
1967
|
+
break;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
catch (error) {
|
|
1971
|
+
return blockWithFailure("journal-read", "historical-source-recheck", error, entry.updateId);
|
|
1972
|
+
}
|
|
1973
|
+
if (review === "retain") {
|
|
1974
|
+
claims.set(entry.updateId, "retained");
|
|
1975
|
+
transition("deferred", entry.updateId);
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
if (review && historical && spendHistorical && !entry.routingInput && classifyPending === deps.shouldReviewHistoricalInput) {
|
|
1979
|
+
completedUpdateIds.push({ updateId: entry.updateId, spent: true });
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
if (review) {
|
|
1983
|
+
claims.set(entry.updateId, "historical");
|
|
1984
|
+
transition("deferred", entry.updateId);
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (entry.state === "pending" && entry.routingInput) {
|
|
1989
|
+
claims.set(entry.updateId, "historical");
|
|
1990
|
+
transition("deferred", entry.updateId);
|
|
1991
|
+
continue;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1495
1994
|
clearRetryTimer();
|
|
1496
1995
|
transition("executing", entry.updateId);
|
|
1497
1996
|
const execution = await executeWithinOwner(expectedOwner, entry.update);
|
|
@@ -1532,7 +2031,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1532
2031
|
for (const sourceUpdateId of receipt.sourceUpdateIds)
|
|
1533
2032
|
claims.set(sourceUpdateId, "queued");
|
|
1534
2033
|
try {
|
|
1535
|
-
publishCommittedQueueReceipt(receipt, outcome.queueReceipt.queueOwner, expectedOwner
|
|
2034
|
+
await publishCommittedQueueReceipt(receipt, outcome.queueReceipt.queueOwner, expectedOwner);
|
|
1536
2035
|
}
|
|
1537
2036
|
catch (error) {
|
|
1538
2037
|
return blockWithFailure("invalid-outcome", "queue-receipt-publish", error, entry.updateId);
|
|
@@ -1547,7 +2046,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1547
2046
|
return postExecutionAuthority;
|
|
1548
2047
|
const claimableUpdateIds = new Set([
|
|
1549
2048
|
entry.updateId,
|
|
1550
|
-
...claims.
|
|
2049
|
+
...[...claims].filter(([, claim]) => claim !== "abandoning" && claim !== "historical" && claim !== "retained").map(([id]) => id),
|
|
1551
2050
|
]);
|
|
1552
2051
|
let outcome;
|
|
1553
2052
|
try {
|
|
@@ -1557,7 +2056,10 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1557
2056
|
return blockWithFailure("invalid-outcome", "invalid-outcome", error, entry.updateId);
|
|
1558
2057
|
}
|
|
1559
2058
|
if (outcome.kind === "deferred") {
|
|
1560
|
-
|
|
2059
|
+
if (outcome.routingReview && !deferredSources.get(entry.updateId)?.historical) {
|
|
2060
|
+
return blockWithFailure("invalid-outcome", "historical-review-source", new TelegramUpdateAdmissionOutcomeError("Historical review needs an exact startup source."), entry.updateId);
|
|
2061
|
+
}
|
|
2062
|
+
claims.set(entry.updateId, outcome.routingReview ? "historical" : "deferred");
|
|
1561
2063
|
transition("deferred", entry.updateId);
|
|
1562
2064
|
continue;
|
|
1563
2065
|
}
|
|
@@ -1565,7 +2067,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1565
2067
|
const completionResult = commitCompletedBatch(expectedOwner, completedUpdateIds);
|
|
1566
2068
|
if (completionResult)
|
|
1567
2069
|
return completionResult;
|
|
1568
|
-
const queuedResult = commitQueuedOutcome(expectedOwner, entry.updateId, outcome);
|
|
2070
|
+
const queuedResult = await commitQueuedOutcome(expectedOwner, entry.updateId, outcome);
|
|
1569
2071
|
if (queuedResult === "blocked" || queuedResult === "aborted") {
|
|
1570
2072
|
return queuedResult;
|
|
1571
2073
|
}
|
|
@@ -1573,7 +2075,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1573
2075
|
snapshotInvalidated = true;
|
|
1574
2076
|
break;
|
|
1575
2077
|
}
|
|
1576
|
-
completedUpdateIds.push(entry.updateId);
|
|
2078
|
+
completedUpdateIds.push({ updateId: entry.updateId, ...(outcome.expectedSource ? { expectedSource: outcome.expectedSource } : {}) });
|
|
1577
2079
|
}
|
|
1578
2080
|
const completionResult = commitCompletedBatch(expectedOwner, completedUpdateIds);
|
|
1579
2081
|
if (completionResult)
|
|
@@ -1592,6 +2094,14 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1592
2094
|
const expectedOwner = owner;
|
|
1593
2095
|
if (!expectedOwner || drainPromise)
|
|
1594
2096
|
return;
|
|
2097
|
+
let startupKey;
|
|
2098
|
+
if (onHeldSourcesPrepared) {
|
|
2099
|
+
try {
|
|
2100
|
+
startupKey = deps.getJournalBindingKey?.();
|
|
2101
|
+
}
|
|
2102
|
+
catch { /* Missing startup identity grants no notification. */ }
|
|
2103
|
+
}
|
|
2104
|
+
let lastResult;
|
|
1595
2105
|
const run = async () => {
|
|
1596
2106
|
while (pendingSignal &&
|
|
1597
2107
|
owner === expectedOwner &&
|
|
@@ -1599,7 +2109,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1599
2109
|
pendingSignal = false;
|
|
1600
2110
|
// Keep newer wakes in this run so waitForDrain also covers them. A failure
|
|
1601
2111
|
// observed after signal() still keeps its latch; this loop never clears it.
|
|
1602
|
-
await drain(expectedOwner);
|
|
2112
|
+
lastResult = await drain(expectedOwner);
|
|
1603
2113
|
}
|
|
1604
2114
|
};
|
|
1605
2115
|
const operation = run();
|
|
@@ -1610,6 +2120,32 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1610
2120
|
if (pendingSignal && !expectedOwner.controller.signal.aborted) {
|
|
1611
2121
|
launchDrain();
|
|
1612
2122
|
}
|
|
2123
|
+
else if (lastResult === "idle" && startupKey && onHeldSourcesPrepared && !expectedOwner.heldSourcesPreparedIssued) {
|
|
2124
|
+
try {
|
|
2125
|
+
const ctx = expectedOwner.ctx, key = startupKey;
|
|
2126
|
+
const isCurrent = () => owner === expectedOwner && !blocked && !expectedOwner.controller.signal.aborted &&
|
|
2127
|
+
deps.getJournalBindingKey?.() === key && deps.isContextCurrent?.(ctx) !== false && deps.hasAuthority(ctx);
|
|
2128
|
+
if (!isCurrent())
|
|
2129
|
+
return;
|
|
2130
|
+
expectedOwner.heldSourcesPreparedIssued = true;
|
|
2131
|
+
// Do not couple lifecycle/stop or the worker drain to a held controller/API reply.
|
|
2132
|
+
void Promise.resolve().then(() => isCurrent() ? onHeldSourcesPrepared({ ctx,
|
|
2133
|
+
journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent,
|
|
2134
|
+
routingSourceIds: [...deferredSources].filter(([, source]) => source.journalBindingKey === key && !!source.entry.routingInput)
|
|
2135
|
+
.map(([id]) => id) }) : undefined).catch(error => {
|
|
2136
|
+
try {
|
|
2137
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" });
|
|
2138
|
+
}
|
|
2139
|
+
catch { /* Diagnostic only. */ }
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
catch (error) {
|
|
2143
|
+
try {
|
|
2144
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" });
|
|
2145
|
+
}
|
|
2146
|
+
catch { /* Diagnostic only. */ }
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
1613
2149
|
}
|
|
1614
2150
|
};
|
|
1615
2151
|
tracked = operation.then(() => finish(), (error) => {
|
|
@@ -1621,6 +2157,76 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1621
2157
|
});
|
|
1622
2158
|
drainPromise = tracked;
|
|
1623
2159
|
};
|
|
2160
|
+
const abandonSource = (input, historicalSource) => {
|
|
2161
|
+
const expectedOwner = owner;
|
|
2162
|
+
const source = deferredSources.get(input.updateId);
|
|
2163
|
+
const abandonPending = deps.journal.abandonPending;
|
|
2164
|
+
const isCurrent = () => {
|
|
2165
|
+
if (!expectedOwner || owner !== expectedOwner || input.signal.aborted ||
|
|
2166
|
+
expectedOwner.controller.signal !== input.signal || !source ||
|
|
2167
|
+
source.entry.routingInput?.phase === "selected" || (source.historical && historicalSource !== source) ||
|
|
2168
|
+
(claims.get(input.updateId) !== "deferred" && claims.get(input.updateId) !== "abandoning" &&
|
|
2169
|
+
!(claims.get(input.updateId) === "historical" && historicalSource === source)))
|
|
2170
|
+
return false;
|
|
2171
|
+
return input.isCurrent() && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
2172
|
+
deps.getJournalBindingKey?.() === source.journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
2173
|
+
};
|
|
2174
|
+
if (!source || !abandonPending || !isCurrent())
|
|
2175
|
+
return undefined;
|
|
2176
|
+
// Retain this claim on failure, including unknown commit: never reopen dispatch.
|
|
2177
|
+
claims.set(input.updateId, "abandoning");
|
|
2178
|
+
try {
|
|
2179
|
+
const result = abandonPending({ ...input, entry: source.entry,
|
|
2180
|
+
journalBindingKey: source.journalBindingKey, isCurrent });
|
|
2181
|
+
claims.delete(input.updateId);
|
|
2182
|
+
deferredSources.delete(input.updateId);
|
|
2183
|
+
state.journalEntryCount = result.entryCount;
|
|
2184
|
+
state.journalSerializedBytes = result.serializedBytes;
|
|
2185
|
+
transition("idle");
|
|
2186
|
+
pendingSignal = true;
|
|
2187
|
+
launchDrain();
|
|
2188
|
+
return result;
|
|
2189
|
+
}
|
|
2190
|
+
catch (error) {
|
|
2191
|
+
recordRuntimeEvent(error, { phase: "pending-abandonment", updateId: input.updateId });
|
|
2192
|
+
updateClaimCounts();
|
|
2193
|
+
notifyStateChange();
|
|
2194
|
+
throw error;
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
const inspectRecovery = (input, kind) => {
|
|
2198
|
+
const expectedOwner = owner;
|
|
2199
|
+
if (!expectedOwner)
|
|
2200
|
+
return undefined;
|
|
2201
|
+
const { signal, journalBindingKey, isCurrent: callerIsCurrent, afterUpdateId } = input;
|
|
2202
|
+
const isOwnerCurrent = () => owner === expectedOwner && expectedOwner.controller.signal === signal && !signal.aborted;
|
|
2203
|
+
const isCurrent = () => isOwnerCurrent() && callerIsCurrent() && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
2204
|
+
deps.getJournalBindingKey?.() === journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
2205
|
+
if (!isCurrent() || !deps.journal.abandonPending || !deps.journal.inspectPendingRetention ||
|
|
2206
|
+
(afterUpdateId !== undefined && (!Number.isSafeInteger(afterUpdateId) || afterUpdateId < 0)))
|
|
2207
|
+
return undefined;
|
|
2208
|
+
const matches = [...deferredSources].filter(([id, source]) => claims.get(id) === kind &&
|
|
2209
|
+
source.journalBindingKey === journalBindingKey && (afterUpdateId === undefined || id > afterUpdateId))
|
|
2210
|
+
.sort(([a], [b]) => a - b);
|
|
2211
|
+
const sources = matches.slice(0, TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT).map(([updateId, source]) => {
|
|
2212
|
+
let committed;
|
|
2213
|
+
return { original: structuredClone(source.entry), retry(authority) {
|
|
2214
|
+
const authorized = () => isOwnerCurrent() && authority.isCurrent() && isCurrent();
|
|
2215
|
+
if (!authorized())
|
|
2216
|
+
return undefined;
|
|
2217
|
+
if (committed)
|
|
2218
|
+
return { ...committed, duplicate: true };
|
|
2219
|
+
const stillProtected = () => authorized() && (claims.get(updateId) === kind || claims.get(updateId) === "abandoning") &&
|
|
2220
|
+
deferredSources.get(updateId) === source;
|
|
2221
|
+
if (!stillProtected())
|
|
2222
|
+
return undefined;
|
|
2223
|
+
committed = abandonSource({ ...authority, updateId, signal, isCurrent: stillProtected }, source);
|
|
2224
|
+
return committed;
|
|
2225
|
+
} };
|
|
2226
|
+
});
|
|
2227
|
+
return isCurrent() ? { sources,
|
|
2228
|
+
...(matches.length > TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT ? { nextAfterUpdateId: matches[TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT - 1][0] } : {}) } : undefined;
|
|
2229
|
+
};
|
|
1624
2230
|
return {
|
|
1625
2231
|
start(ctx) {
|
|
1626
2232
|
if (owner) {
|
|
@@ -1631,6 +2237,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1631
2237
|
return;
|
|
1632
2238
|
}
|
|
1633
2239
|
clearRetryTimer();
|
|
2240
|
+
clearRoutingTimer();
|
|
1634
2241
|
const nowMs = getNowMs();
|
|
1635
2242
|
const generation = ++nextGeneration;
|
|
1636
2243
|
const queueOwnerIdentity = resolveQueueOwnerIdentity(ctx, generation);
|
|
@@ -1641,6 +2248,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1641
2248
|
queueOwnerIdentity,
|
|
1642
2249
|
};
|
|
1643
2250
|
claims.clear();
|
|
2251
|
+
deferredSources.clear();
|
|
1644
2252
|
committedQueueReceipts.clear();
|
|
1645
2253
|
blocked = false;
|
|
1646
2254
|
pendingSignal = true;
|
|
@@ -1653,6 +2261,8 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1653
2261
|
state.journalSerializedBytes = 0;
|
|
1654
2262
|
state.oldestAdmittedAtMs = undefined;
|
|
1655
2263
|
state.deferredClaimCount = 0;
|
|
2264
|
+
delete state.abandoningClaimCount;
|
|
2265
|
+
delete state.historicalClaimCount;
|
|
1656
2266
|
state.queuedClaimCount = 0;
|
|
1657
2267
|
state.foreignQueuedCount = 0;
|
|
1658
2268
|
delete state.foreignQueuedOwner;
|
|
@@ -1683,7 +2293,56 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1683
2293
|
pendingSignal = true;
|
|
1684
2294
|
launchDrain();
|
|
1685
2295
|
},
|
|
1686
|
-
|
|
2296
|
+
armRoutingInput(input) { return mutateRoutingSource(input, false)?.lifetime; },
|
|
2297
|
+
selectRoutingInput(input) { return mutateRoutingSource(input, true)?.issued === true; },
|
|
2298
|
+
isRoutingInputCurrent(input) {
|
|
2299
|
+
return owner?.controller.signal === input.signal && !input.signal.aborted &&
|
|
2300
|
+
deferredSources.has(input.updateId) && claims.get(input.updateId) !== "abandoning";
|
|
2301
|
+
},
|
|
2302
|
+
inspectDeferredSource(input) {
|
|
2303
|
+
const expectedOwner = owner;
|
|
2304
|
+
const source = deferredSources.get(input.updateId);
|
|
2305
|
+
const current = () => {
|
|
2306
|
+
if (!expectedOwner || owner !== expectedOwner || input.signal.aborted || expectedOwner.controller.signal !== input.signal ||
|
|
2307
|
+
!source || deferredSources.get(input.updateId) !== source || claims.get(input.updateId) !== "deferred" ||
|
|
2308
|
+
deps.getJournalBindingKey?.() !== source.journalBindingKey || deps.isContextCurrent?.(expectedOwner.ctx) === false ||
|
|
2309
|
+
!deps.hasAuthority(expectedOwner.ctx))
|
|
2310
|
+
return false;
|
|
2311
|
+
return isQueueOwnerIdentityCurrent(expectedOwner);
|
|
2312
|
+
};
|
|
2313
|
+
if (!current())
|
|
2314
|
+
return undefined;
|
|
2315
|
+
const snapshot = deps.journal.read();
|
|
2316
|
+
const entry = snapshot.entries.find(value => value.updateId === input.updateId);
|
|
2317
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) || !entry ||
|
|
2318
|
+
JSON.stringify(entry) !== JSON.stringify(source.entry) || !current())
|
|
2319
|
+
return undefined;
|
|
2320
|
+
return { journalBindingKey: source.journalBindingKey, ...createTelegramUpdateJournalEntryDigest(entry) };
|
|
2321
|
+
},
|
|
2322
|
+
isHistoricalSource(input) {
|
|
2323
|
+
const source = deferredSources.get(input.updateId);
|
|
2324
|
+
const isCurrent = () => owner?.controller.signal === input.signal && !input.signal.aborted &&
|
|
2325
|
+
source?.historical === true && deferredSources.get(input.updateId) === source;
|
|
2326
|
+
if (!isCurrent())
|
|
2327
|
+
return false;
|
|
2328
|
+
// Domain eligibility observes original evidence, never mutable handler projections.
|
|
2329
|
+
return (!input.matchesOriginal || input.matchesOriginal(structuredClone(source.entry)) === true) && isCurrent();
|
|
2330
|
+
},
|
|
2331
|
+
supportsDeferredAbandonment(input) {
|
|
2332
|
+
const expectedOwner = owner;
|
|
2333
|
+
const source = deferredSources.get(input.updateId);
|
|
2334
|
+
if (!expectedOwner || input.signal.aborted || expectedOwner.controller.signal !== input.signal ||
|
|
2335
|
+
!source || source.historical || input.journalBindingKey !== source.journalBindingKey ||
|
|
2336
|
+
!deps.journal.abandonPending || !deps.journal.inspectPendingRetention ||
|
|
2337
|
+
claims.get(input.updateId) === "queued")
|
|
2338
|
+
return false;
|
|
2339
|
+
return deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
2340
|
+
deps.getJournalBindingKey?.() === source.journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
2341
|
+
},
|
|
2342
|
+
abandonDeferred(input) { return abandonSource(input); },
|
|
2343
|
+
inspectAbandoning(input) { return inspectRecovery(input, "abandoning"); },
|
|
2344
|
+
inspectHistorical(input) { return inspectRecovery(input, "historical"); },
|
|
2345
|
+
async settleDeferred(input) {
|
|
1687
2346
|
const expectedOwner = owner;
|
|
1688
2347
|
if (!expectedOwner ||
|
|
1689
2348
|
expectedOwner.controller.signal !== input.signal ||
|
|
@@ -1700,11 +2359,25 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1700
2359
|
return;
|
|
1701
2360
|
}
|
|
1702
2361
|
const claim = claims.get(input.updateId);
|
|
2362
|
+
if (claim === "abandoning" || claim === "historical" || claim === "retained" ||
|
|
2363
|
+
(!claim && routingJournal?.inspectExpiry(input.updateId)))
|
|
2364
|
+
return;
|
|
1703
2365
|
if (input.outcome.kind === "complete") {
|
|
1704
2366
|
// Expiry may retire only a still-deferred source, never accepted queue work.
|
|
1705
2367
|
if (claim !== "deferred")
|
|
1706
2368
|
return;
|
|
1707
|
-
|
|
2369
|
+
let completion;
|
|
2370
|
+
try {
|
|
2371
|
+
completion = validateTelegramUpdateAdmissionOutcome(input.outcome, input.updateId, new Set([input.updateId]));
|
|
2372
|
+
}
|
|
2373
|
+
catch (error) {
|
|
2374
|
+
blockWithFailure("invalid-outcome", "late-invalid-completion", error, input.updateId);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (completion.kind !== "complete")
|
|
2378
|
+
return;
|
|
2379
|
+
const result = commitCompletedBatch(expectedOwner, [{ updateId: input.updateId,
|
|
2380
|
+
...(completion.expectedSource ? { expectedSource: completion.expectedSource } : {}) }]);
|
|
1708
2381
|
if (!result)
|
|
1709
2382
|
transition("idle", input.updateId);
|
|
1710
2383
|
return;
|
|
@@ -1721,7 +2394,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1721
2394
|
}
|
|
1722
2395
|
let outcome;
|
|
1723
2396
|
try {
|
|
1724
|
-
outcome = validateTelegramUpdateAdmissionOutcome(input.outcome, input.updateId, new Set([input.updateId, ...claims.
|
|
2397
|
+
outcome = validateTelegramUpdateAdmissionOutcome(input.outcome, input.updateId, new Set([input.updateId, ...[...claims].filter(([, claim]) => claim !== "abandoning" && claim !== "historical" && claim !== "retained").map(([id]) => id)]));
|
|
1725
2398
|
}
|
|
1726
2399
|
catch (error) {
|
|
1727
2400
|
blockWithFailure("invalid-outcome", "late-invalid-outcome", error, input.updateId);
|
|
@@ -1737,7 +2410,8 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1737
2410
|
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
1738
2411
|
return;
|
|
1739
2412
|
}
|
|
1740
|
-
const
|
|
2413
|
+
const publication = commitQueuedOutcome(expectedOwner, input.updateId, outcome);
|
|
2414
|
+
const result = typeof publication === "string" ? publication : await publication;
|
|
1741
2415
|
if (result === "blocked") {
|
|
1742
2416
|
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
1743
2417
|
return;
|
|
@@ -1746,7 +2420,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1746
2420
|
return;
|
|
1747
2421
|
transition("queued", input.updateId);
|
|
1748
2422
|
},
|
|
1749
|
-
settleCustodied(input) {
|
|
2423
|
+
async settleCustodied(input) {
|
|
1750
2424
|
const expectedOwner = owner;
|
|
1751
2425
|
if (!expectedOwner || expectedOwner.controller.signal !== input.signal || input.signal.aborted)
|
|
1752
2426
|
return;
|
|
@@ -1791,7 +2465,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1791
2465
|
for (const sourceUpdateId of receipt.sourceUpdateIds)
|
|
1792
2466
|
claims.set(sourceUpdateId, "queued");
|
|
1793
2467
|
try {
|
|
1794
|
-
publishCommittedQueueReceipt(receipt, input.result.queueReceipt.queueOwner, expectedOwner
|
|
2468
|
+
await publishCommittedQueueReceipt(receipt, input.result.queueReceipt.queueOwner, expectedOwner);
|
|
1795
2469
|
}
|
|
1796
2470
|
catch (error) {
|
|
1797
2471
|
blockWithFailure("invalid-outcome", "queue-receipt-publish", error, input.updateId);
|
|
@@ -1804,16 +2478,32 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1804
2478
|
const committed = getCurrentQueueReceipt(receipt);
|
|
1805
2479
|
return committed ? { ...committed.queueOwner } : undefined;
|
|
1806
2480
|
},
|
|
1807
|
-
|
|
2481
|
+
getQueueReceiptSettlementOwner(receipt, ctx, reason) {
|
|
2482
|
+
const expectedOwner = owner, committed = committedQueueReceipts.get(receipt.receiptId);
|
|
2483
|
+
if (!expectedOwner || expectedOwner.controller.signal.aborted ||
|
|
2484
|
+
!(deps.isContextCurrent?.(ctx) ?? expectedOwner.ctx === ctx) || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
2485
|
+
!committed || !areTelegramQueueAdmissionReceiptsEqual(committed.receipt, normalizeQueueReceipt(receipt)) ||
|
|
2486
|
+
(deps.getJournalBindingKey && committed.receipt.journalBindingKey !== deps.getJournalBindingKey()) ||
|
|
2487
|
+
!isTelegramUpdateJournalQueueOwnerProcess(committed.queueOwner, expectedOwner.queueOwnerIdentity))
|
|
2488
|
+
return undefined;
|
|
2489
|
+
const attempt = scopedQueueCompletionAttempts.get(receipt.receiptId);
|
|
2490
|
+
if (attempt ? attempt.owner !== expectedOwner || attempt.reason !== reason || attempt.journalBindingKey !== deps.getJournalBindingKey?.()
|
|
2491
|
+
: !getCurrentQueueReceipt(receipt))
|
|
2492
|
+
return undefined;
|
|
2493
|
+
return { ...committed.queueOwner };
|
|
2494
|
+
},
|
|
2495
|
+
completeQueueReceipts: function completeQueueReceipts(input) {
|
|
1808
2496
|
const expectedOwner = owner;
|
|
1809
2497
|
if (!expectedOwner ||
|
|
1810
2498
|
expectedOwner.controller.signal.aborted ||
|
|
1811
2499
|
!(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx)) {
|
|
1812
2500
|
return false;
|
|
1813
2501
|
}
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
2502
|
+
// An ACK permits only local completion reconciliation, never execution readiness.
|
|
2503
|
+
const receipts = input.sourceCompletions === undefined ? input.receipts.filter(receipt => !isQueueReceiptCompletionAcknowledged(receipt)) : input.receipts;
|
|
2504
|
+
if (receipts.length === 0)
|
|
2505
|
+
return input.sourceCompletions === undefined;
|
|
2506
|
+
const normalizedReceipts = receipts.map(normalizeQueueReceipt);
|
|
1817
2507
|
const receiptIds = new Set();
|
|
1818
2508
|
const sourceUpdateIds = new Set();
|
|
1819
2509
|
const queuedCompletions = [];
|
|
@@ -1837,9 +2527,91 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1837
2527
|
sourceUpdateIds.add(updateId);
|
|
1838
2528
|
}
|
|
1839
2529
|
}
|
|
2530
|
+
const completionBinding = deps.getJournalBindingKey?.();
|
|
2531
|
+
const ordinaryObserverBound = normalizedReceipts.every(receipt => committedQueueReceipts.get(receipt.receiptId).receipt.journalBindingKey === completionBinding);
|
|
2532
|
+
const ordinaryCompletionCurrent = () => ordinaryObserverBound && owner === expectedOwner &&
|
|
2533
|
+
!expectedOwner.controller.signal.aborted && (deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) &&
|
|
2534
|
+
isQueueOwnerIdentityCurrent(expectedOwner) && deps.getJournalBindingKey?.() === completionBinding;
|
|
1840
2535
|
let removedUpdateIds;
|
|
2536
|
+
let scopedCompletionCurrent;
|
|
1841
2537
|
try {
|
|
1842
|
-
|
|
2538
|
+
const prepared = normalizedReceipts.flatMap(receipt => committedQueueReceipts.get(receipt.receiptId).sourceCompletions ?? []);
|
|
2539
|
+
const sourceCompletions = input.sourceCompletions === undefined ? (prepared.length ? prepared : undefined) : input.sourceCompletions;
|
|
2540
|
+
if (input.sourceCompletions !== undefined && prepared.some(expected => !input.sourceCompletions.some(value => isDeepStrictEqual(value, expected)))) {
|
|
2541
|
+
throw new Error("Telegram queued completion conflicts with its prepared source scopes.");
|
|
2542
|
+
}
|
|
2543
|
+
if (input.sourceCompletions === undefined && prepared.length && receipts.some(receipt => !requiredScopedQueueReceipts.has(receipt.receiptId))) {
|
|
2544
|
+
const scoped = receipts.filter(receipt => requiredScopedQueueReceipts.has(receipt.receiptId));
|
|
2545
|
+
const ordinary = receipts.filter(receipt => !requiredScopedQueueReceipts.has(receipt.receiptId));
|
|
2546
|
+
if (!scoped.length || !ordinary.length)
|
|
2547
|
+
throw new Error("Telegram mixed queue completion has incomplete scoped authority.");
|
|
2548
|
+
const binding = deps.getJournalBindingKey?.();
|
|
2549
|
+
const current = () => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
2550
|
+
(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) &&
|
|
2551
|
+
!!binding && deps.getJournalBindingKey?.() === binding;
|
|
2552
|
+
// Independent whole receipts need independent ACKs. Ordinary siblings must not acquire invented scopes.
|
|
2553
|
+
if (!current() || !completeQueueReceipts({ receipts: scoped, ctx: input.ctx, reason: input.reason }) || !current())
|
|
2554
|
+
return false;
|
|
2555
|
+
return completeQueueReceipts({ receipts: ordinary, ctx: input.ctx, reason: input.reason });
|
|
2556
|
+
}
|
|
2557
|
+
if (sourceCompletions === undefined) {
|
|
2558
|
+
if (queuedCompletions.some(receipt => requiredScopedQueueReceipts.has(receipt.receiptId))) {
|
|
2559
|
+
throw new Error("Telegram scoped queue completion cannot downgrade required source scopes.");
|
|
2560
|
+
}
|
|
2561
|
+
removedUpdateIds = deps.journal.completeQueued(queuedCompletions).removedUpdateIds;
|
|
2562
|
+
}
|
|
2563
|
+
else {
|
|
2564
|
+
for (const receipt of queuedCompletions)
|
|
2565
|
+
requiredScopedQueueReceipts.add(receipt.receiptId);
|
|
2566
|
+
queuedCompletions.sort((a, b) => a.receiptId < b.receiptId ? -1 : a.receiptId > b.receiptId ? 1 : 0);
|
|
2567
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
2568
|
+
const current = () => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
2569
|
+
(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) &&
|
|
2570
|
+
deps.getJournalBindingKey?.() === journalBindingKey;
|
|
2571
|
+
if (!journalBindingKey || !current() || !completeQueuedExact || !inspectCompletion ||
|
|
2572
|
+
!Array.isArray(sourceCompletions) || input.sourceCompletions !== undefined && sourceCompletions.length !== sourceUpdateIds.size) {
|
|
2573
|
+
throw new Error("Telegram scoped queue completion requires full current source authority.");
|
|
2574
|
+
}
|
|
2575
|
+
scopedCompletionCurrent = current;
|
|
2576
|
+
const completions = normalizeTelegramQueueSourceCompletions(sourceCompletions, sourceUpdateIds, journalBindingKey, input.sourceCompletions !== undefined)
|
|
2577
|
+
.map(({ journalBindingKey: _binding, ...completion }) => completion);
|
|
2578
|
+
if (queuedCompletions.some(receipt => !completions.some(scope => receipt.sourceUpdateIds.includes(scope.updateId)))) {
|
|
2579
|
+
throw new Error("Telegram scoped queue completion requires a proven origin for every whole receipt.");
|
|
2580
|
+
}
|
|
2581
|
+
const previous = queuedCompletions.map(receipt => scopedQueueCompletionAttempts.get(receipt.receiptId));
|
|
2582
|
+
const retained = previous.find(attempt => attempt !== undefined);
|
|
2583
|
+
if (retained && (previous.some(attempt => attempt !== retained) || retained.owner !== expectedOwner ||
|
|
2584
|
+
retained.journalBindingKey !== journalBindingKey || retained.reason !== input.reason ||
|
|
2585
|
+
!isDeepStrictEqual(retained.receipts, queuedCompletions) || !isDeepStrictEqual(retained.completions, completions))) {
|
|
2586
|
+
throw new Error("Telegram scoped queue completion has conflicting issued authority.");
|
|
2587
|
+
}
|
|
2588
|
+
if (!retained) {
|
|
2589
|
+
const attempt = { owner: expectedOwner, journalBindingKey, reason: input.reason,
|
|
2590
|
+
receipts: queuedCompletions.map(receipt => ({ ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...receipt.queueOwner } })),
|
|
2591
|
+
completions: completions.map(completion => ({ ...completion })) };
|
|
2592
|
+
if (!current())
|
|
2593
|
+
throw new Error("Telegram scoped queue completion authority changed before disposal.");
|
|
2594
|
+
// Mark before issuance: an exception may follow a committed rename, and cannot license another disposal.
|
|
2595
|
+
for (const receipt of queuedCompletions)
|
|
2596
|
+
scopedQueueCompletionAttempts.set(receipt.receiptId, attempt);
|
|
2597
|
+
const result = completeQueuedExact(queuedCompletions.map(receipt => ({ ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...receipt.queueOwner } })), completions.map(completion => ({ ...completion })));
|
|
2598
|
+
if (!current() || !isDeepStrictEqual(result.sourceCompletions, completions)) {
|
|
2599
|
+
throw new Error("Telegram scoped queue completion ACK was not confirmed.");
|
|
2600
|
+
}
|
|
2601
|
+
const removed = new Set(result.removedUpdateIds);
|
|
2602
|
+
if (removed.size !== sourceUpdateIds.size || [...sourceUpdateIds].some(id => !removed.has(id))) {
|
|
2603
|
+
throw new Error("Telegram scoped queue completion did not remove every source.");
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
for (const completion of completions) {
|
|
2607
|
+
if (!current() || !isDeepStrictEqual(inspectCompletion({ ...completion }), completion) || !current()) {
|
|
2608
|
+
throw new Error("Telegram scoped queue completion ACK was not retained under current authority.");
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
// Every immutable whole receipt has a scoped queued-origin witness; native queued ACK continuity proves its complete removal.
|
|
2612
|
+
// Unscoped siblings receive only receipt acknowledgement, never a source marker or absence-based proof.
|
|
2613
|
+
removedUpdateIds = [...sourceUpdateIds];
|
|
2614
|
+
}
|
|
1843
2615
|
}
|
|
1844
2616
|
catch (error) {
|
|
1845
2617
|
blockWithFailure("journal-write", "queue-receipt-completion", error);
|
|
@@ -1851,13 +2623,15 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1851
2623
|
blockWithFailure("journal-write", "queue-receipt-completion", new Error(`Telegram ${input.reason} did not complete every receipt source.`));
|
|
1852
2624
|
return false;
|
|
1853
2625
|
}
|
|
1854
|
-
|
|
2626
|
+
receipts.forEach((receipt, index) => {
|
|
1855
2627
|
acknowledgedQueueReceiptCompletions.set(receipt, normalizedReceipts[index]);
|
|
1856
2628
|
});
|
|
1857
2629
|
for (const updateId of sourceUpdateIds)
|
|
1858
2630
|
claims.delete(updateId);
|
|
1859
2631
|
for (const receiptId of receiptIds) {
|
|
1860
2632
|
committedQueueReceipts.delete(receiptId);
|
|
2633
|
+
requiredScopedQueueReceipts.delete(receiptId);
|
|
2634
|
+
scopedQueueCompletionAttempts.delete(receiptId);
|
|
1861
2635
|
}
|
|
1862
2636
|
const completedUpdateIds = [...sourceUpdateIds];
|
|
1863
2637
|
state.lastCompletedUpdateId = Math.max(state.lastCompletedUpdateId ?? -1, ...completedUpdateIds);
|
|
@@ -1865,8 +2639,33 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1865
2639
|
state.journalEntryCount = Math.max(0, state.journalEntryCount - completedUpdateIds.length);
|
|
1866
2640
|
updateClaimCounts();
|
|
1867
2641
|
notifyStateChange();
|
|
1868
|
-
|
|
1869
|
-
|
|
2642
|
+
if (scopedCompletionCurrent)
|
|
2643
|
+
for (const receipt of normalizedReceipts) {
|
|
2644
|
+
if (!scopedCompletionCurrent())
|
|
2645
|
+
break;
|
|
2646
|
+
try {
|
|
2647
|
+
observeQueuedCompletion?.(normalizeQueueReceipt(receipt), input.ctx);
|
|
2648
|
+
}
|
|
2649
|
+
catch (error) {
|
|
2650
|
+
recordRuntimeEvent(error, { phase: "queue-receipt-completion-observer", receiptId: receipt.receiptId });
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
// Ordinary queued disposal is source completion too; notify only after a whole positive ACK and fresh origin authority.
|
|
2654
|
+
if (!scopedCompletionCurrent)
|
|
2655
|
+
for (const updateId of completedUpdateIds) {
|
|
2656
|
+
try {
|
|
2657
|
+
if (!ordinaryCompletionCurrent())
|
|
2658
|
+
break;
|
|
2659
|
+
deps.onUpdateCompleted?.(updateId, input.ctx, completionBinding);
|
|
2660
|
+
}
|
|
2661
|
+
catch (error) {
|
|
2662
|
+
recordRuntimeEvent(error, { phase: "queue-source-completion-observer", updateId });
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
if (!scopedCompletionCurrent || scopedCompletionCurrent()) {
|
|
2666
|
+
pendingSignal = true;
|
|
2667
|
+
launchDrain();
|
|
2668
|
+
}
|
|
1870
2669
|
return true;
|
|
1871
2670
|
},
|
|
1872
2671
|
async stop() {
|
|
@@ -1875,6 +2674,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1875
2674
|
return;
|
|
1876
2675
|
pendingSignal = false;
|
|
1877
2676
|
clearRetryTimer();
|
|
2677
|
+
clearRoutingTimer();
|
|
1878
2678
|
expectedOwner.controller.abort();
|
|
1879
2679
|
await drainPromise?.catch(() => undefined);
|
|
1880
2680
|
if (owner !== expectedOwner)
|
|
@@ -1882,6 +2682,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1882
2682
|
owner = undefined;
|
|
1883
2683
|
drainPromise = undefined;
|
|
1884
2684
|
claims.clear();
|
|
2685
|
+
deferredSources.clear();
|
|
1885
2686
|
committedQueueReceipts.clear();
|
|
1886
2687
|
blocked = false;
|
|
1887
2688
|
state.phase = "stopped";
|
|
@@ -1889,6 +2690,8 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1889
2690
|
state.currentUpdateId = undefined;
|
|
1890
2691
|
state.blockedReason = undefined;
|
|
1891
2692
|
state.deferredClaimCount = 0;
|
|
2693
|
+
delete state.abandoningClaimCount;
|
|
2694
|
+
delete state.historicalClaimCount;
|
|
1892
2695
|
state.queuedClaimCount = 0;
|
|
1893
2696
|
state.foreignQueuedCount = 0;
|
|
1894
2697
|
delete state.foreignQueuedOwner;
|
|
@@ -1907,8 +2710,9 @@ export function createTelegramUpdateWorkerRuntime(deps) {
|
|
|
1907
2710
|
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
1908
2711
|
notifyStateChange();
|
|
1909
2712
|
},
|
|
1910
|
-
waitForDrain() {
|
|
1911
|
-
|
|
2713
|
+
async waitForDrain() {
|
|
2714
|
+
await (drainPromise ?? Promise.resolve());
|
|
2715
|
+
await Promise.allSettled([...pendingQueuePublications.values()].map(value => value.task));
|
|
1912
2716
|
},
|
|
1913
2717
|
getState() {
|
|
1914
2718
|
return getTelegramUpdateWorkerStateSnapshot(state);
|
|
@@ -2019,8 +2823,21 @@ function mergeTelegramReportedAdmissionOutcome(current, next, updateId) {
|
|
|
2019
2823
|
return next;
|
|
2020
2824
|
if (next.kind === "deferred")
|
|
2021
2825
|
return current;
|
|
2022
|
-
if (current.kind === "complete" && next.kind === "complete")
|
|
2023
|
-
|
|
2826
|
+
if (current.kind === "complete" && next.kind === "complete") {
|
|
2827
|
+
if (!current.expectedSource)
|
|
2828
|
+
return next;
|
|
2829
|
+
if (!next.expectedSource)
|
|
2830
|
+
return current;
|
|
2831
|
+
if (current.expectedSource.updateId === next.expectedSource.updateId &&
|
|
2832
|
+
current.expectedSource.journalBindingKey === next.expectedSource.journalBindingKey &&
|
|
2833
|
+
current.expectedSource.sourceSha256 === next.expectedSource.sourceSha256) {
|
|
2834
|
+
if (!current.expectedSource.completionSha256)
|
|
2835
|
+
return next;
|
|
2836
|
+
if (!next.expectedSource.completionSha256 || current.expectedSource.completionSha256 === next.expectedSource.completionSha256)
|
|
2837
|
+
return current;
|
|
2838
|
+
}
|
|
2839
|
+
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${updateId} reported conflicting completion sources.`);
|
|
2840
|
+
}
|
|
2024
2841
|
if (current.kind === "queued" && next.kind === "queued" &&
|
|
2025
2842
|
areTelegramQueueAdmissionReceiptsEqual(current, next))
|
|
2026
2843
|
return current;
|
|
@@ -2736,13 +3553,22 @@ export function createTelegramUpdateAdmissionHandle(deps) {
|
|
|
2736
3553
|
let nextExecutionGeneration = 0;
|
|
2737
3554
|
return async (update, ctx, signal) => {
|
|
2738
3555
|
const generation = ++nextExecutionGeneration;
|
|
3556
|
+
let suspended = false;
|
|
3557
|
+
let routingClaims = 0;
|
|
3558
|
+
let abandoned;
|
|
3559
|
+
let routingLifetime;
|
|
3560
|
+
let routingSourceIds;
|
|
3561
|
+
let routingClockUnknown = false;
|
|
3562
|
+
let routingSelectionAttempted = false;
|
|
3563
|
+
let routingSelectionConfirmed = false;
|
|
2739
3564
|
const execution = {
|
|
2740
3565
|
generation,
|
|
2741
3566
|
updateId: update.update_id,
|
|
2742
3567
|
signal,
|
|
2743
|
-
isCurrent: () => !signal.aborted
|
|
3568
|
+
isCurrent: () => !signal.aborted && !suspended && ((!routingLifetime && !routingClockUnknown) || immediate || settlementReported ||
|
|
3569
|
+
deps.isRoutingInputCurrent?.({ updateId: update.update_id, signal }) !== false),
|
|
2744
3570
|
assertCurrent() {
|
|
2745
|
-
if (
|
|
3571
|
+
if (!execution.isCurrent()) {
|
|
2746
3572
|
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
2747
3573
|
}
|
|
2748
3574
|
},
|
|
@@ -2753,8 +3579,19 @@ export function createTelegramUpdateAdmissionHandle(deps) {
|
|
|
2753
3579
|
if (verdict === "consume")
|
|
2754
3580
|
return { kind: "complete" };
|
|
2755
3581
|
let immediate = true;
|
|
3582
|
+
let settlementReported = false;
|
|
2756
3583
|
let outcome;
|
|
2757
3584
|
const boundUpdate = bindTelegramUpdateExecutionFence(bindTelegramUpdateAdmissionSource(update, (next) => {
|
|
3585
|
+
if (!execution.isCurrent())
|
|
3586
|
+
return;
|
|
3587
|
+
if (next.kind === "deferred" && next.routingReview) {
|
|
3588
|
+
if (!immediate || settlementReported || routingClaims > 0 || !deps.isHistoricalSource?.({ updateId: update.update_id, signal })) {
|
|
3589
|
+
throw new TelegramUpdateAdmissionOutcomeError("Historical review cannot override accepted or selected work.");
|
|
3590
|
+
}
|
|
3591
|
+
suspended = true;
|
|
3592
|
+
}
|
|
3593
|
+
if (next.kind !== "deferred")
|
|
3594
|
+
settlementReported = true;
|
|
2758
3595
|
if (immediate) {
|
|
2759
3596
|
outcome = mergeTelegramReportedAdmissionOutcome(outcome, next, update.update_id);
|
|
2760
3597
|
return;
|
|
@@ -2763,11 +3600,15 @@ export function createTelegramUpdateAdmissionHandle(deps) {
|
|
|
2763
3600
|
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${update.update_id} reported a late outcome without an owner.`);
|
|
2764
3601
|
}
|
|
2765
3602
|
void Promise.resolve()
|
|
2766
|
-
.then(() =>
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
3603
|
+
.then(() => {
|
|
3604
|
+
if (!execution.isCurrent())
|
|
3605
|
+
return;
|
|
3606
|
+
return deps.onLateOutcome(next, {
|
|
3607
|
+
updateId: update.update_id,
|
|
3608
|
+
ctx,
|
|
3609
|
+
signal,
|
|
3610
|
+
});
|
|
3611
|
+
})
|
|
2771
3612
|
.catch((error) => {
|
|
2772
3613
|
try {
|
|
2773
3614
|
deps.onLateOutcomeError?.(error, update.update_id);
|
|
@@ -2776,11 +3617,81 @@ export function createTelegramUpdateAdmissionHandle(deps) {
|
|
|
2776
3617
|
// Diagnostic sinks must not create an unhandled late Promise.
|
|
2777
3618
|
}
|
|
2778
3619
|
});
|
|
2779
|
-
}
|
|
3620
|
+
}, { abandon: (authority) => {
|
|
3621
|
+
const isCurrent = () => !signal.aborted && routingClaims === 0 && !settlementReported && authority.isCurrent();
|
|
3622
|
+
if (immediate || outcome?.kind !== "deferred" || !deps.abandonDeferred || !isCurrent())
|
|
3623
|
+
return undefined;
|
|
3624
|
+
if (abandoned)
|
|
3625
|
+
return { ...abandoned, duplicate: true };
|
|
3626
|
+
const wasSuspended = suspended;
|
|
3627
|
+
suspended = true;
|
|
3628
|
+
// Failed/unknown publication keeps dispatch suspended for exact cancellation retry.
|
|
3629
|
+
// An ineligible request that attempted no publication remains inert.
|
|
3630
|
+
const result = deps.abandonDeferred({ ...authority, updateId: update.update_id, signal, isCurrent });
|
|
3631
|
+
if (result)
|
|
3632
|
+
abandoned = result;
|
|
3633
|
+
else
|
|
3634
|
+
suspended = wasSuspended;
|
|
3635
|
+
return result;
|
|
3636
|
+
}, inspectAbandoning: request => execution.isCurrent()
|
|
3637
|
+
? deps.inspectAbandoning?.({ ...request, signal }) : undefined,
|
|
3638
|
+
inspectHistorical: request => execution.isCurrent() ? deps.inspectHistorical?.({ ...request, signal }) : undefined,
|
|
3639
|
+
inspectSource: () => execution.isCurrent() && !immediate && !settlementReported
|
|
3640
|
+
? deps.inspectDeferredSource?.({ updateId: update.update_id, signal }) : undefined,
|
|
3641
|
+
isHistorical: matchesOriginal => !signal.aborted && deps.isHistoricalSource?.({ updateId: update.update_id, signal,
|
|
3642
|
+
...(matchesOriginal ? { matchesOriginal } : {}) }) === true,
|
|
3643
|
+
isHistoricalReviewHeld: () => !signal.aborted && suspended && outcome?.kind === "deferred" && outcome.routingReview === true,
|
|
3644
|
+
supportsAbandonment: journalBindingKey => !signal.aborted && !settlementReported &&
|
|
3645
|
+
deps.supportsDeferredAbandonment?.({ updateId: update.update_id, signal, journalBindingKey }) === true,
|
|
3646
|
+
armRoutingInput: (operatorUserId, sourceUpdateIds) => {
|
|
3647
|
+
execution.assertCurrent();
|
|
3648
|
+
if (settlementReported || routingClaims || suspended)
|
|
3649
|
+
return undefined;
|
|
3650
|
+
if (routingClockUnknown)
|
|
3651
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input clock publication is unconfirmed.");
|
|
3652
|
+
const ids = [...sourceUpdateIds];
|
|
3653
|
+
if (routingSourceIds && !isDeepStrictEqual(ids, routingSourceIds))
|
|
3654
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input source membership changed.");
|
|
3655
|
+
try {
|
|
3656
|
+
routingLifetime = deps.armRoutingInput?.({ updateId: update.update_id, signal, operatorUserId, sourceUpdateIds: ids });
|
|
3657
|
+
}
|
|
3658
|
+
catch (error) {
|
|
3659
|
+
routingClockUnknown = true;
|
|
3660
|
+
throw error;
|
|
3661
|
+
}
|
|
3662
|
+
if (routingLifetime)
|
|
3663
|
+
routingSourceIds = ids;
|
|
3664
|
+
return routingLifetime ? { ...routingLifetime } : undefined;
|
|
3665
|
+
},
|
|
3666
|
+
acquireRouting: (select = false, sourceUpdateIds) => {
|
|
3667
|
+
execution.assertCurrent();
|
|
3668
|
+
if (select && routingClockUnknown)
|
|
3669
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input clock publication is unconfirmed; selection is protected.");
|
|
3670
|
+
if (select && routingLifetime && !routingSelectionConfirmed) {
|
|
3671
|
+
if (routingSelectionAttempted)
|
|
3672
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram routing selection is unconfirmed; no replay is permitted.");
|
|
3673
|
+
if (!isDeepStrictEqual(sourceUpdateIds ?? [update.update_id], routingSourceIds))
|
|
3674
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input selection changed its source membership.");
|
|
3675
|
+
routingSelectionAttempted = true;
|
|
3676
|
+
routingSelectionConfirmed = deps.selectRoutingInput?.({ updateId: update.update_id, signal,
|
|
3677
|
+
operatorUserId: routingLifetime.operatorUserId, sourceUpdateIds: routingSourceIds }) === true;
|
|
3678
|
+
if (!routingSelectionConfirmed)
|
|
3679
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input choice is expired or unavailable.");
|
|
3680
|
+
}
|
|
3681
|
+
routingClaims += 1;
|
|
3682
|
+
let released = false;
|
|
3683
|
+
return () => {
|
|
3684
|
+
if (released)
|
|
3685
|
+
return;
|
|
3686
|
+
released = true;
|
|
3687
|
+
routingClaims -= 1;
|
|
3688
|
+
};
|
|
3689
|
+
} }), execution);
|
|
2780
3690
|
try {
|
|
2781
3691
|
execution.assertCurrent();
|
|
2782
3692
|
await deps.defaultHandle(boundUpdate, ctx, execution);
|
|
2783
|
-
|
|
3693
|
+
if (outcome?.kind !== "deferred" || !outcome.routingReview || signal.aborted)
|
|
3694
|
+
execution.assertCurrent();
|
|
2784
3695
|
}
|
|
2785
3696
|
finally {
|
|
2786
3697
|
immediate = false;
|
|
@@ -2813,17 +3724,21 @@ export function createTelegramQueueAdmissionSettlementMuxRuntime(runtimes) {
|
|
|
2813
3724
|
// Plan the whole request before mutation; unready is not an acknowledgement of completion.
|
|
2814
3725
|
const complete = (items, ctx, kind) => {
|
|
2815
3726
|
const plan = new Map();
|
|
3727
|
+
const reason = kind === "prompt" ? "prompt-handoff" : kind === "control" ? "control-settlement" : "discard";
|
|
3728
|
+
const settlementOwner = (runtime, receipt) => runtime.getQueueReceiptSettlementOwner ? runtime.getQueueReceiptSettlementOwner(receipt, ctx, reason) :
|
|
3729
|
+
runtime.isItemReady({ admissionReceipts: [receipt] }) ? runtime.getQueueReceiptOwner(receipt) : undefined;
|
|
2816
3730
|
for (const receipt of items.flatMap(item => item.admissionReceipts ?? [])) {
|
|
2817
|
-
if (
|
|
3731
|
+
if (isQueueReceiptCompletionAcknowledged(receipt))
|
|
2818
3732
|
continue;
|
|
2819
|
-
const candidates = runtimes.filter(runtime => runtime.
|
|
3733
|
+
const candidates = runtimes.filter(runtime => runtime.getQueueReceiptSettlementOwner ? !!settlementOwner(runtime, receipt)
|
|
3734
|
+
: runtime.isItemReady({ admissionReceipts: [receipt] }));
|
|
2820
3735
|
const selected = candidates[0];
|
|
2821
3736
|
if (!selected)
|
|
2822
3737
|
return false;
|
|
2823
3738
|
if (candidates.length > 1) {
|
|
2824
|
-
const owner = selected
|
|
3739
|
+
const owner = settlementOwner(selected, receipt);
|
|
2825
3740
|
if (!owner || candidates.some(runtime => {
|
|
2826
|
-
const candidate = runtime
|
|
3741
|
+
const candidate = settlementOwner(runtime, receipt);
|
|
2827
3742
|
return !candidate || !areTelegramUpdateJournalQueueOwnersEqual(owner, candidate);
|
|
2828
3743
|
}))
|
|
2829
3744
|
return false;
|
|
@@ -2868,7 +3783,7 @@ export function createTelegramQueueAdmissionSettlementRuntime(worker) {
|
|
|
2868
3783
|
const receipts = [];
|
|
2869
3784
|
for (const item of items) {
|
|
2870
3785
|
for (const receipt of item.admissionReceipts ?? []) {
|
|
2871
|
-
if (
|
|
3786
|
+
if (!isQueueReceiptCompletionAcknowledged(receipt))
|
|
2872
3787
|
receipts.push(receipt);
|
|
2873
3788
|
}
|
|
2874
3789
|
}
|
|
@@ -2877,6 +3792,7 @@ export function createTelegramQueueAdmissionSettlementRuntime(worker) {
|
|
|
2877
3792
|
return {
|
|
2878
3793
|
isItemReady: (item) => (item.admissionReceipts ?? []).every(worker.isQueueReceiptCommitted),
|
|
2879
3794
|
getQueueReceiptOwner: worker.getQueueReceiptOwner,
|
|
3795
|
+
...(worker.getQueueReceiptSettlementOwner ? { getQueueReceiptSettlementOwner: worker.getQueueReceiptSettlementOwner } : {}),
|
|
2880
3796
|
onPromptHandedOff: (item, ctx) => complete([item], ctx, "prompt-handoff"),
|
|
2881
3797
|
onControlSettled: (item, ctx) => complete([item], ctx, "control-settlement"),
|
|
2882
3798
|
onItemsDiscarded: (items, ctx) => complete(items, ctx, "discard"),
|
|
@@ -3048,9 +3964,9 @@ export function createTelegramQueueHandoffReconciliationRuntimeAssembly(deps) {
|
|
|
3048
3964
|
canHandoffWithLeader: deps.canHandoffWithLeader,
|
|
3049
3965
|
listFollowers: deps.listFollowers,
|
|
3050
3966
|
createRecipientJournalBindingKey(recipient) {
|
|
3051
|
-
if (!recipient.profileKey)
|
|
3967
|
+
if (!recipient.profileKey || !recipient.sessionId)
|
|
3052
3968
|
return undefined;
|
|
3053
|
-
return deps.createRecipientJournalResolver(recipient.profileKey)()
|
|
3969
|
+
return deps.createRecipientJournalResolver(recipient.profileKey, recipient.sessionId)()
|
|
3054
3970
|
?.recoveryKey;
|
|
3055
3971
|
},
|
|
3056
3972
|
getQueuedItems: deps.queueStore.getQueuedItems,
|
|
@@ -3228,17 +4144,18 @@ export function createTelegramUpdateWorkerOwnerRuntime(deps) {
|
|
|
3228
4144
|
sessionGeneration: deps.getSessionGeneration(),
|
|
3229
4145
|
};
|
|
3230
4146
|
},
|
|
3231
|
-
onQueueReceiptCommitted(
|
|
4147
|
+
onQueueReceiptCommitted(receipt, ctx) {
|
|
3232
4148
|
if (!deps.isContextCurrent(ctx))
|
|
3233
4149
|
return;
|
|
4150
|
+
deps.afterQueueReceiptCommitted?.(receipt, ctx);
|
|
3234
4151
|
deps.dispatchNext(ctx);
|
|
3235
4152
|
deps.requestQueueHandoffReconciliation(ctx);
|
|
3236
4153
|
},
|
|
3237
|
-
onUpdateCompleted(updateId, ctx) {
|
|
4154
|
+
onUpdateCompleted(updateId, ctx, journalBindingKey) {
|
|
3238
4155
|
if (!deps.isContextCurrent(ctx))
|
|
3239
4156
|
return;
|
|
3240
4157
|
deps.dispatchNext(ctx);
|
|
3241
|
-
deps.afterUpdateCompleted?.(updateId);
|
|
4158
|
+
deps.afterUpdateCompleted?.(updateId, ctx, journalBindingKey);
|
|
3242
4159
|
},
|
|
3243
4160
|
};
|
|
3244
4161
|
}
|
|
@@ -3247,6 +4164,7 @@ export function createTelegramUpdateAdmissionRuntimeBinding(deps) {
|
|
|
3247
4164
|
let follower;
|
|
3248
4165
|
let settlement;
|
|
3249
4166
|
let inputCustodyBus;
|
|
4167
|
+
const getActive = () => deps.isFollowerRegistered() ? follower : leader;
|
|
3250
4168
|
return {
|
|
3251
4169
|
bind(input) {
|
|
3252
4170
|
leader = input.leader;
|
|
@@ -3259,7 +4177,7 @@ export function createTelegramUpdateAdmissionRuntimeBinding(deps) {
|
|
|
3259
4177
|
},
|
|
3260
4178
|
getLeader: () => leader,
|
|
3261
4179
|
getFollower: () => follower,
|
|
3262
|
-
getActive
|
|
4180
|
+
getActive,
|
|
3263
4181
|
getSettlement: () => settlement,
|
|
3264
4182
|
getInputCustodyBus: () => inputCustodyBus,
|
|
3265
4183
|
getLifecycleForJournalBinding(journalBindingKey) {
|
|
@@ -3358,6 +4276,7 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
|
|
|
3358
4276
|
binding.recoveryKey !== recoveryKey) {
|
|
3359
4277
|
throw new Error("Telegram update source binding changed during startup.");
|
|
3360
4278
|
}
|
|
4279
|
+
deps.prepareBinding?.(binding);
|
|
3361
4280
|
const release = deps.acquireSourceReference?.(binding);
|
|
3362
4281
|
const inputCustody = binding.journal.inputCustody;
|
|
3363
4282
|
try {
|
|
@@ -3580,6 +4499,15 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
|
|
|
3580
4499
|
const executeUpdate = createTelegramUpdateAdmissionHandle({
|
|
3581
4500
|
defaultHandle: deps.defaultHandle,
|
|
3582
4501
|
registry: deps.registry,
|
|
4502
|
+
abandonDeferred: input => worker?.abandonDeferred?.(input),
|
|
4503
|
+
armRoutingInput: input => worker?.armRoutingInput?.(input),
|
|
4504
|
+
selectRoutingInput: input => worker?.selectRoutingInput?.(input) === true,
|
|
4505
|
+
isRoutingInputCurrent: input => worker?.isRoutingInputCurrent?.(input) !== false,
|
|
4506
|
+
supportsDeferredAbandonment: input => worker?.supportsDeferredAbandonment?.(input) === true,
|
|
4507
|
+
inspectAbandoning: input => worker?.inspectAbandoning?.(input),
|
|
4508
|
+
inspectHistorical: input => worker?.inspectHistorical?.(input),
|
|
4509
|
+
inspectDeferredSource: input => worker?.inspectDeferredSource?.(input),
|
|
4510
|
+
isHistoricalSource: input => worker?.isHistoricalSource?.(input) === true,
|
|
3583
4511
|
onLateOutcome(outcome, details) {
|
|
3584
4512
|
worker?.settleDeferred({
|
|
3585
4513
|
updateId: details.updateId,
|
|
@@ -3647,6 +4575,7 @@ export function createTelegramUpdateAdmissionLifecycleAssembly(deps) {
|
|
|
3647
4575
|
const follower = createTelegramUpdateAdmissionLifecycleRuntime({
|
|
3648
4576
|
getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
|
|
3649
4577
|
...(deps.acquireSourceReference ? { acquireSourceReference: binding => deps.acquireSourceReference("follower", binding) } : {}),
|
|
4578
|
+
...(deps.follower.prepareBinding ? { prepareBinding: deps.follower.prepareBinding } : {}),
|
|
3650
4579
|
resolveBinding() {
|
|
3651
4580
|
const generation = deps.follower.getGeneration();
|
|
3652
4581
|
if (!deps.follower.isRegistered() || !generation)
|
|
@@ -3671,6 +4600,11 @@ export function createTelegramUpdateAdmissionLifecycleAssembly(deps) {
|
|
|
3671
4600
|
getJournalBindingKey: () => binding.recoveryKey,
|
|
3672
4601
|
getRecipientBindingKey: () => binding.recipientBindingKey,
|
|
3673
4602
|
hasAuthority: () => binding.hasAuthority?.() ?? false,
|
|
4603
|
+
// Recipient custody is not an orphaned direct-bot source.
|
|
4604
|
+
shouldReviewHistoricalInput: undefined,
|
|
4605
|
+
shouldHoldPendingInput: undefined,
|
|
4606
|
+
spendHistoricalInput: undefined,
|
|
4607
|
+
onHeldSourcesPrepared: undefined,
|
|
3674
4608
|
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
3675
4609
|
});
|
|
3676
4610
|
},
|