@llblab/pi-kit 0.27.6 → 0.28.0
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/BACKLOG.md +2 -5
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
- package/node_modules/@llblab/pi-telegram/README.md +15 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
- package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
- package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
- package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
- package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
- package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
- package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
- package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
- package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
- package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
- package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
- package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
- package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
- package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
- package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
- package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
- package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
- package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
- package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
- package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
- package/node_modules/@llblab/pi-telegram/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
- package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/package.json +2 -2
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { isDeepStrictEqual } from "node:util";
|
|
9
|
+
import {
|
|
10
|
+
isWireRecord as isTelegramUpdateAdmissionRecord,
|
|
11
|
+
isNonEmptyWireString as isTelegramUpdateAdmissionString,
|
|
12
|
+
} from "./wire.ts";
|
|
8
13
|
|
|
9
14
|
import {
|
|
10
15
|
createTelegramPrivateTarget,
|
|
@@ -23,10 +28,16 @@ import {
|
|
|
23
28
|
TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
24
29
|
TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION,
|
|
25
30
|
TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
|
|
31
|
+
isTelegramUpdateJournalLegacyFamilyVersion,
|
|
26
32
|
TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH,
|
|
27
33
|
TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH,
|
|
28
34
|
TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH,
|
|
29
35
|
areTelegramUpdateJournalQueueOwnersEqual,
|
|
36
|
+
createTelegramUpdateJournalEntryDigest,
|
|
37
|
+
type TelegramUpdateJournalEntryDigest,
|
|
38
|
+
type TelegramUpdateJournalSourceCompletion,
|
|
39
|
+
type TelegramUpdateJournalQueuedCompletion,
|
|
40
|
+
type TelegramUpdateJournalQueuedReceiptEvidence,
|
|
30
41
|
getTelegramUpdateJournalBindingPath,
|
|
31
42
|
isTelegramUpdateJournalQueueOwnerProcess,
|
|
32
43
|
parseTelegramUpdateJournalQueueOwner,
|
|
@@ -39,6 +50,12 @@ import {
|
|
|
39
50
|
type TelegramUpdateJournalInputClaim,
|
|
40
51
|
type TelegramUpdateJournalOperatorDispositionInput,
|
|
41
52
|
type TelegramUpdateJournalOperatorDispositionResult,
|
|
53
|
+
type TelegramUpdateJournalPendingAbandonmentInput,
|
|
54
|
+
type TelegramUpdateJournalPendingAbandonmentResult,
|
|
55
|
+
type TelegramUpdateJournalPendingRetentionEvidence,
|
|
56
|
+
type TelegramUpdateJournalEntry,
|
|
57
|
+
type TelegramUpdateJournalRoutingInput,
|
|
58
|
+
type TelegramRoutingInputJournal,
|
|
42
59
|
type TelegramUpdateJournalQueueDiscardResult,
|
|
43
60
|
type TelegramUpdateJournalQueueHandoffAcceptResult,
|
|
44
61
|
type TelegramUpdateJournalQueueHandoffCancelResult,
|
|
@@ -134,30 +151,6 @@ function getTelegramReactionEmoji(
|
|
|
134
151
|
return candidates.find((emoji) => emojis.has(emoji));
|
|
135
152
|
}
|
|
136
153
|
|
|
137
|
-
export function getTelegramQueueReactionDisposition(
|
|
138
|
-
reactions: TelegramReactionType[],
|
|
139
|
-
): TelegramQueueReactionDisposition {
|
|
140
|
-
const emojis = collectTelegramReactionEmojis(reactions);
|
|
141
|
-
const suppressionEmoji = getTelegramReactionEmoji(
|
|
142
|
-
emojis,
|
|
143
|
-
TELEGRAM_REMOVAL_REACTION_EMOJIS,
|
|
144
|
-
);
|
|
145
|
-
const priorityEmoji = getTelegramReactionEmoji(
|
|
146
|
-
emojis,
|
|
147
|
-
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
148
|
-
);
|
|
149
|
-
if (suppressionEmoji && priorityEmoji) {
|
|
150
|
-
return {
|
|
151
|
-
kind: "priority-suppressed",
|
|
152
|
-
priorityEmoji,
|
|
153
|
-
suppressionEmoji,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
if (suppressionEmoji) return { kind: "suppressed", emoji: suppressionEmoji };
|
|
157
|
-
if (priorityEmoji) return { kind: "priority", emoji: priorityEmoji };
|
|
158
|
-
return { kind: "default" };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
154
|
function getTelegramQueueReactionTransition(
|
|
162
155
|
oldReactions: TelegramReactionType[],
|
|
163
156
|
newReactions: TelegramReactionType[],
|
|
@@ -283,6 +276,8 @@ export interface TelegramGuestMessage {
|
|
|
283
276
|
from?: TelegramUser;
|
|
284
277
|
message_id?: number;
|
|
285
278
|
text?: string;
|
|
279
|
+
/** Text sent together with a photo, document or other media. */
|
|
280
|
+
caption?: string;
|
|
286
281
|
reply_to_message?: TelegramUpdateMessage;
|
|
287
282
|
}
|
|
288
283
|
|
|
@@ -405,7 +400,7 @@ type TelegramForeignUpdateSettlementFailure =
|
|
|
405
400
|
sourceUpdateId?: number;
|
|
406
401
|
};
|
|
407
402
|
|
|
408
|
-
|
|
403
|
+
class TelegramForeignUpdateSettlementError extends Error {
|
|
409
404
|
readonly settlement: TelegramForeignUpdateSettlementFailure;
|
|
410
405
|
|
|
411
406
|
constructor(
|
|
@@ -463,8 +458,8 @@ export interface TelegramUpdateFlow
|
|
|
463
458
|
}
|
|
464
459
|
|
|
465
460
|
export type TelegramUpdateAdmissionOutcome =
|
|
466
|
-
| { kind: "complete" }
|
|
467
|
-
| { kind: "deferred" }
|
|
461
|
+
| { kind: "complete"; expectedSource?: TelegramDeferredSourceEvidence }
|
|
462
|
+
| { kind: "deferred"; routingReview?: true }
|
|
468
463
|
| {
|
|
469
464
|
kind: "queued";
|
|
470
465
|
queueKind: "prompt" | "control";
|
|
@@ -481,9 +476,61 @@ const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol(
|
|
|
481
476
|
"telegram.update-admission.binding",
|
|
482
477
|
);
|
|
483
478
|
|
|
479
|
+
export type TelegramDeferredUpdateAbandonmentAuthority = Pick<
|
|
480
|
+
TelegramUpdateJournalPendingAbandonmentInput, "operatorAuthorityId" | "isCurrent"
|
|
481
|
+
>;
|
|
482
|
+
|
|
483
|
+
export interface TelegramDeferredAbandonmentRecoveryRequest {
|
|
484
|
+
journalBindingKey: string;
|
|
485
|
+
afterUpdateId?: number;
|
|
486
|
+
isCurrent: () => boolean;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT = 20;
|
|
490
|
+
|
|
491
|
+
/** A generation-owned post-drain hint for the new-world restart; never execution, completion or deletion authority by itself. */
|
|
492
|
+
export interface TelegramHeldSourcePreparation<TContext> {
|
|
493
|
+
ctx: TContext;
|
|
494
|
+
journalBindingKey: string;
|
|
495
|
+
/** Exact prepared worker owner, context, key and authority; the consumer adds its own domain fences. */
|
|
496
|
+
isCurrent: () => boolean;
|
|
497
|
+
signal: AbortSignal;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface TelegramDeferredAbandonmentRecoveryPage {
|
|
501
|
+
sources: {
|
|
502
|
+
/** Detached original evidence, not proof of current eligibility or archive integrity. */
|
|
503
|
+
original: TelegramUpdateJournalEntry;
|
|
504
|
+
retry: (authority: TelegramDeferredUpdateAbandonmentAuthority) =>
|
|
505
|
+
TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
506
|
+
}[];
|
|
507
|
+
nextAfterUpdateId?: number;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
type TelegramHistoricalInputPredicate = (original: TelegramUpdateJournalEntry) => boolean;
|
|
511
|
+
|
|
512
|
+
/** Read-only exact-source observation by its live worker; not an acceptance or removal grant. */
|
|
513
|
+
export interface TelegramDeferredSourceEvidence extends TelegramUpdateJournalEntryDigest {
|
|
514
|
+
journalBindingKey: string;
|
|
515
|
+
/** Supplied only after caller-owned acceptance publication; requires a durable removal ACK. */
|
|
516
|
+
completionSha256?: string;
|
|
517
|
+
}
|
|
518
|
+
|
|
484
519
|
interface TelegramUpdateAdmissionBinding {
|
|
485
520
|
sourceUpdateId: number;
|
|
486
521
|
report: (outcome: TelegramUpdateAdmissionOutcome) => void;
|
|
522
|
+
abandon?: (authority: TelegramDeferredUpdateAbandonmentAuthority) =>
|
|
523
|
+
TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
524
|
+
armRoutingInput?: (operatorUserId: number, sourceUpdateIds: readonly number[]) => TelegramUpdateJournalRoutingInput | undefined;
|
|
525
|
+
acquireRouting?: (select?: boolean, sourceUpdateIds?: readonly number[]) => () => void;
|
|
526
|
+
inspectSource?: () => TelegramDeferredSourceEvidence | undefined;
|
|
527
|
+
supportsAbandonment?: (journalBindingKey: string) => boolean;
|
|
528
|
+
inspectAbandoning?: (request: TelegramDeferredAbandonmentRecoveryRequest) =>
|
|
529
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
530
|
+
inspectHistorical?: (request: TelegramDeferredAbandonmentRecoveryRequest) =>
|
|
531
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
532
|
+
isHistorical?: (matchesOriginal?: TelegramHistoricalInputPredicate) => boolean;
|
|
533
|
+
isHistoricalReviewHeld?: () => boolean;
|
|
487
534
|
}
|
|
488
535
|
|
|
489
536
|
export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
|
|
@@ -520,6 +567,7 @@ export function bindTelegramUpdateAdmissionSource<
|
|
|
520
567
|
>(
|
|
521
568
|
update: TUpdate,
|
|
522
569
|
report: TelegramUpdateAdmissionBinding["report"],
|
|
570
|
+
controls?: Pick<TelegramUpdateAdmissionBinding, "abandon" | "armRoutingInput" | "acquireRouting" | "inspectSource" | "supportsAbandonment" | "inspectAbandoning" | "inspectHistorical" | "isHistorical" | "isHistoricalReviewHeld">,
|
|
523
571
|
): TUpdate {
|
|
524
572
|
if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
|
|
525
573
|
throw new TelegramUpdateAdmissionOutcomeError(
|
|
@@ -529,6 +577,7 @@ export function bindTelegramUpdateAdmissionSource<
|
|
|
529
577
|
const binding: TelegramUpdateAdmissionBinding = {
|
|
530
578
|
sourceUpdateId: update.update_id,
|
|
531
579
|
report,
|
|
580
|
+
...controls,
|
|
532
581
|
};
|
|
533
582
|
const callbackQuery = update.callback_query
|
|
534
583
|
? bindTelegramUpdateAdmissionCarrier(
|
|
@@ -575,6 +624,26 @@ export function bindTelegramUpdateAdmissionSource<
|
|
|
575
624
|
} as TUpdate;
|
|
576
625
|
}
|
|
577
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Conservative read-only census: any retained entry naming this Thread, in any nested Telegram object or state,
|
|
629
|
+
* is unresolved custody. It cannot see updates still in transit before journal append.
|
|
630
|
+
*/
|
|
631
|
+
export function collectTelegramJournalThreadUpdateIds(
|
|
632
|
+
entries: readonly { updateId: number; update: unknown }[],
|
|
633
|
+
target: { chatId: number; threadId: number },
|
|
634
|
+
): number[] {
|
|
635
|
+
const matches = (value: unknown, depth: number): boolean => {
|
|
636
|
+
if (depth > 8 || !value || typeof value !== "object") return false;
|
|
637
|
+
const record = value as Record<string, unknown>;
|
|
638
|
+
if (record.message_thread_id === target.threadId) {
|
|
639
|
+
const chat = record.chat as { id?: unknown } | undefined;
|
|
640
|
+
if (!chat || typeof chat !== "object" || chat.id === target.chatId) return true;
|
|
641
|
+
}
|
|
642
|
+
return Object.values(record).some(child => matches(child, depth + 1));
|
|
643
|
+
};
|
|
644
|
+
return entries.filter(entry => matches(entry.update, 0)).map(entry => entry.updateId).sort((left, right) => left - right);
|
|
645
|
+
}
|
|
646
|
+
|
|
578
647
|
export function collectTelegramAdmissionSourceUpdateIds(
|
|
579
648
|
values: readonly unknown[],
|
|
580
649
|
): number[] {
|
|
@@ -586,29 +655,132 @@ export function collectTelegramAdmissionSourceUpdateIds(
|
|
|
586
655
|
return [...sourceUpdateIds].sort((left, right) => left - right);
|
|
587
656
|
}
|
|
588
657
|
|
|
658
|
+
/** Observe the original journal entry, never the mutable routed carrier, through current worker authority. */
|
|
659
|
+
export function inspectTelegramDeferredSource(value: unknown): TelegramDeferredSourceEvidence | undefined {
|
|
660
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
661
|
+
if (execution?.isCurrent() !== true) return undefined;
|
|
662
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectSource?.();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** A route-owned pre-disposition acceptance publisher; wraps only this carrier, never the shared worker binding. */
|
|
666
|
+
export function bindTelegramUpdateCompletionAcceptance<TValue>(value: TValue,
|
|
667
|
+
publish: () => TelegramDeferredSourceEvidence): TValue {
|
|
668
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
669
|
+
if (!binding || getTelegramUpdateExecutionFence(value)?.isCurrent() !== true) {
|
|
670
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance requires a live source carrier.");
|
|
671
|
+
}
|
|
672
|
+
let retained: TelegramDeferredSourceEvidence | undefined;
|
|
673
|
+
let queued = false;
|
|
674
|
+
const scoped: TelegramUpdateAdmissionBinding = { ...binding, report(outcome) {
|
|
675
|
+
if (outcome.kind !== "complete") {
|
|
676
|
+
if (outcome.kind === "queued") queued = true;
|
|
677
|
+
binding.report(outcome); return;
|
|
678
|
+
}
|
|
679
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
680
|
+
// A command's trailing implicit completion cannot dispose receipt-owned or uncertain queued work.
|
|
681
|
+
if (queued) {
|
|
682
|
+
if (outcome.expectedSource) throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance cannot dispose a queued source.");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!retained) {
|
|
686
|
+
const published = validateTelegramUpdateAdmissionOutcome({ kind: "complete", expectedSource: publish() },
|
|
687
|
+
binding.sourceUpdateId, new Set([binding.sourceUpdateId]));
|
|
688
|
+
if (published.kind !== "complete" || !published.expectedSource?.completionSha256) {
|
|
689
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance lacks its scoped journal proof.");
|
|
690
|
+
}
|
|
691
|
+
retained = published.expectedSource;
|
|
692
|
+
}
|
|
693
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
694
|
+
if (outcome.expectedSource && (outcome.expectedSource.updateId !== retained.updateId ||
|
|
695
|
+
outcome.expectedSource.journalBindingKey !== retained.journalBindingKey || outcome.expectedSource.sourceSha256 !== retained.sourceSha256 ||
|
|
696
|
+
outcome.expectedSource.completionSha256 !== undefined && outcome.expectedSource.completionSha256 !== retained.completionSha256)) {
|
|
697
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance conflicts with its reported source.");
|
|
698
|
+
}
|
|
699
|
+
binding.report({ kind: "complete", expectedSource: { ...retained } });
|
|
700
|
+
} };
|
|
701
|
+
return carryTelegramUpdateExecutionFence(value, bindTelegramUpdateAdmissionCarrier(value, scoped)!);
|
|
702
|
+
}
|
|
703
|
+
|
|
589
704
|
/** Report source completion; true means reported, not a durable settlement acknowledgement. */
|
|
590
|
-
export function reportTelegramUpdateCompleted(value: unknown): boolean {
|
|
705
|
+
export function reportTelegramUpdateCompleted(value: unknown, expectedSource?: TelegramDeferredSourceEvidence): boolean {
|
|
591
706
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
592
707
|
if (!binding) return false;
|
|
593
708
|
const execution = getTelegramUpdateExecutionFence(value);
|
|
594
709
|
if (execution && !execution.isCurrent()) return false;
|
|
595
|
-
binding.report({ kind: "complete" })
|
|
710
|
+
binding.report(validateTelegramUpdateAdmissionOutcome({ kind: "complete", ...(expectedSource !== undefined ? { expectedSource } : {}) },
|
|
711
|
+
binding.sourceUpdateId, new Set([binding.sourceUpdateId])));
|
|
596
712
|
return true;
|
|
597
713
|
}
|
|
598
714
|
|
|
599
715
|
export function reportTelegramUpdateDeferred(value: unknown): boolean {
|
|
600
716
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
601
717
|
if (!binding) return false;
|
|
718
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
719
|
+
if (execution && !execution.isCurrent()) return false;
|
|
602
720
|
binding.report({ kind: "deferred" });
|
|
603
721
|
return true;
|
|
604
722
|
}
|
|
605
723
|
|
|
724
|
+
/** Exact source-bound cancellation; undefined means no eligible attempt was made. */
|
|
725
|
+
export function abandonTelegramDeferredUpdate(
|
|
726
|
+
value: unknown,
|
|
727
|
+
authority: TelegramDeferredUpdateAbandonmentAuthority,
|
|
728
|
+
): TelegramUpdateJournalPendingAbandonmentResult | undefined {
|
|
729
|
+
return getTelegramUpdateAdmissionBinding(value)?.abandon?.(authority);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Capability for chooser publication; the eventual action still requires exact deferred authority. */
|
|
733
|
+
export function supportsTelegramDeferredAbandonment(value: unknown, journalBindingKey: string): boolean {
|
|
734
|
+
return getTelegramUpdateAdmissionBinding(value)?.supportsAbandonment?.(journalBindingKey) === true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Observe protected attempts through a current carrier; the UI still owns human authorization. */
|
|
738
|
+
export function inspectTelegramAbandoningUpdates(value: unknown, request: TelegramDeferredAbandonmentRecoveryRequest):
|
|
739
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined {
|
|
740
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectAbandoning?.(request);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Historical inspection is not human confirmation or permission to stop delivery. */
|
|
744
|
+
export function inspectTelegramHistoricalInputs(value: unknown, request: TelegramDeferredAbandonmentRecoveryRequest):
|
|
745
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined {
|
|
746
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectHistorical?.(request);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function isTelegramHistoricalInput(value: unknown, matchesOriginal?: TelegramHistoricalInputPredicate): boolean {
|
|
750
|
+
return getTelegramUpdateAdmissionBinding(value)?.isHistorical?.(matchesOriginal) === true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Routing's last-boundary hold when ownership changed after early classification. */
|
|
754
|
+
export function reportTelegramHistoricalRoutingReview(value: unknown): boolean {
|
|
755
|
+
if (!isTelegramHistoricalInput(value) || getTelegramUpdateExecutionFence(value)?.isCurrent() !== true) return false;
|
|
756
|
+
getTelegramUpdateAdmissionBinding(value)!.report({ kind: "deferred", routingReview: true });
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Arm only a positively published chooser; the journal owns the immutable hour deadline. */
|
|
761
|
+
export function armTelegramRoutingInputs(values: readonly unknown[], operatorUserId: number): TelegramUpdateJournalRoutingInput | undefined {
|
|
762
|
+
const first = values[0], execution = getTelegramUpdateExecutionFence(first);
|
|
763
|
+
if (!execution || values.some(value => getTelegramUpdateExecutionFence(value)?.signal !== execution.signal)) return undefined;
|
|
764
|
+
for (const value of values) assertTelegramUpdateExecutionCurrent(value);
|
|
765
|
+
const ids = collectTelegramAdmissionSourceUpdateIds(values);
|
|
766
|
+
if (!ids.length) return undefined;
|
|
767
|
+
return getTelegramUpdateAdmissionBinding(first)?.armRoutingInput?.(operatorUserId, ids);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Reserve source execution; an actual choice must also freeze its durable TTL before any effect. */
|
|
771
|
+
export function acquireTelegramUpdateRouting(value: unknown, select = false, sourceUpdateIds?: readonly number[]): () => void {
|
|
772
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
773
|
+
return getTelegramUpdateAdmissionBinding(value)?.acquireRouting?.(select, sourceUpdateIds) ?? (() => {});
|
|
774
|
+
}
|
|
775
|
+
|
|
606
776
|
export function reportTelegramQueueAdmission(
|
|
607
777
|
values: readonly unknown[],
|
|
608
778
|
receipts: readonly TelegramQueueAdmissionReceiptLike[],
|
|
609
779
|
): boolean {
|
|
610
780
|
const bindings = new Map<number, TelegramUpdateAdmissionBinding>();
|
|
611
781
|
for (const value of values) {
|
|
782
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
783
|
+
if (execution && !execution.isCurrent()) return false;
|
|
612
784
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
613
785
|
if (!binding) continue;
|
|
614
786
|
const existing = bindings.get(binding.sourceUpdateId);
|
|
@@ -1122,10 +1294,12 @@ function getForeignTelegramCallbackOwnership(
|
|
|
1122
1294
|
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
1123
1295
|
},
|
|
1124
1296
|
): TelegramMessageOwnershipView | undefined {
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1297
|
+
const messageTarget = getTelegramCallbackMessageTarget(query);
|
|
1298
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1299
|
+
const knownOwner = messageTarget && deps.getMessageOwnership?.(messageTarget.chatId, messageTarget.messageId);
|
|
1300
|
+
// A locally published chooser stays local when Restore moves its Thread to a follower.
|
|
1301
|
+
if (currentInstanceId && knownOwner?.instanceId === currentInstanceId) return undefined;
|
|
1302
|
+
const messageOwnership = getForeignTelegramMessageOwnership(messageTarget, deps);
|
|
1129
1303
|
const targetOwnership = getForeignTelegramTargetOwnership(
|
|
1130
1304
|
query.message ? getTelegramMessageTarget(query.message) : undefined,
|
|
1131
1305
|
deps,
|
|
@@ -1621,7 +1795,10 @@ export async function executeTelegramUpdatePlan<
|
|
|
1621
1795
|
) {
|
|
1622
1796
|
assertExecutionCurrent();
|
|
1623
1797
|
await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
|
|
1624
|
-
|
|
1798
|
+
// A terminal historical hold intentionally suspends this exact carrier.
|
|
1799
|
+
// Do not turn its acknowledged deferral into a retryable execution failure.
|
|
1800
|
+
if (getTelegramUpdateExecutionFence(plan.message) !== deps.execution ||
|
|
1801
|
+
getTelegramUpdateAdmissionBinding(plan.message)?.isHistoricalReviewHeld?.() !== true) assertExecutionCurrent();
|
|
1625
1802
|
return;
|
|
1626
1803
|
}
|
|
1627
1804
|
if (plan.kind === "edited-message") {
|
|
@@ -1640,9 +1817,9 @@ export async function executeTelegramUpdatePlan<
|
|
|
1640
1817
|
|
|
1641
1818
|
// --- Durable update worker ---
|
|
1642
1819
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1820
|
+
const TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS = 1_000;
|
|
1821
|
+
const TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS = 60_000;
|
|
1822
|
+
const TELEGRAM_UPDATE_WORKER_BATCH_SIZE = 64;
|
|
1646
1823
|
|
|
1647
1824
|
export type TelegramUpdateWorkerPhase =
|
|
1648
1825
|
| "stopped"
|
|
@@ -1679,6 +1856,10 @@ export interface TelegramUpdateWorkerStateSnapshot {
|
|
|
1679
1856
|
journalSerializedBytes: number;
|
|
1680
1857
|
oldestAdmittedAtMs?: number;
|
|
1681
1858
|
deferredClaimCount: number;
|
|
1859
|
+
/** Protected cancellation attempts, not proof of a committed discard. */
|
|
1860
|
+
abandoningClaimCount?: number;
|
|
1861
|
+
/** Historical sources held before routing, not evidence of prior non-delivery. */
|
|
1862
|
+
historicalClaimCount?: number;
|
|
1682
1863
|
queuedClaimCount: number;
|
|
1683
1864
|
foreignQueuedCount: number;
|
|
1684
1865
|
foreignQueuedOwner?: TelegramUpdateJournalQueueOwner;
|
|
@@ -1712,6 +1893,7 @@ export interface TelegramUpdateWorkerJournalSnapshot {
|
|
|
1712
1893
|
update: TelegramJournaledUpdate;
|
|
1713
1894
|
readonly preApprovalExcluded?: boolean;
|
|
1714
1895
|
admittedAtMs: number;
|
|
1896
|
+
routingInput?: TelegramUpdateJournalRoutingInput;
|
|
1715
1897
|
state: "pending" | "retry-wait" | "queued" | "failed";
|
|
1716
1898
|
inputClaim?: TelegramUpdateJournalInputClaim;
|
|
1717
1899
|
queueKind?: "prompt" | "control";
|
|
@@ -1736,13 +1918,27 @@ export interface TelegramUpdateWorkerJournalSnapshot {
|
|
|
1736
1918
|
serializedBytes: number;
|
|
1737
1919
|
}
|
|
1738
1920
|
|
|
1921
|
+
export interface TelegramRoutingInputExpirySource {
|
|
1922
|
+
original: TelegramUpdateJournalEntry;
|
|
1923
|
+
journalBindingKey: string;
|
|
1924
|
+
isCurrent(): boolean;
|
|
1925
|
+
expire(): TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1739
1928
|
export interface TelegramUpdateWorkerJournalPort {
|
|
1929
|
+
routingInputs?: TelegramRoutingInputJournal;
|
|
1740
1930
|
read: () => TelegramUpdateWorkerJournalSnapshot;
|
|
1931
|
+
inspectPendingRetention?: (entry: TelegramUpdateJournalEntry) =>
|
|
1932
|
+
TelegramUpdateJournalPendingRetentionEvidence | undefined;
|
|
1933
|
+
abandonPending?: (input: TelegramUpdateJournalPendingAbandonmentInput) =>
|
|
1934
|
+
TelegramUpdateJournalPendingAbandonmentResult;
|
|
1741
1935
|
/** Optional strict observation; it must not recover, repair or grant new receipt authority. */
|
|
1742
1936
|
isQueueReceiptCurrent?: (
|
|
1743
1937
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1744
1938
|
owner: TelegramUpdateJournalQueueOwner,
|
|
1745
1939
|
) => boolean;
|
|
1940
|
+
/** Strict full-group origin observation for prepared partial scopes; no writer or readiness acquisition. */
|
|
1941
|
+
inspectQueuedReceipt?: (expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
1746
1942
|
markQueued: (receipt: {
|
|
1747
1943
|
queueKind: "prompt" | "control";
|
|
1748
1944
|
receiptId: string;
|
|
@@ -1763,6 +1959,11 @@ export interface TelegramUpdateWorkerJournalPort {
|
|
|
1763
1959
|
) => {
|
|
1764
1960
|
removedUpdateIds: readonly number[];
|
|
1765
1961
|
};
|
|
1962
|
+
completeQueuedExact?: (receipts: readonly {
|
|
1963
|
+
queueKind: "prompt" | "control"; receiptId: string; sourceUpdateIds: readonly number[]; queueOwner: TelegramUpdateJournalQueueOwner;
|
|
1964
|
+
}[], completions: readonly TelegramUpdateJournalSourceCompletion[]) => {
|
|
1965
|
+
removedUpdateIds: readonly number[]; sourceCompletions?: readonly TelegramUpdateJournalSourceCompletion[];
|
|
1966
|
+
};
|
|
1766
1967
|
markExecutionFailure: (input: {
|
|
1767
1968
|
updateId: number;
|
|
1768
1969
|
expectedAttemptCount: number;
|
|
@@ -1778,6 +1979,32 @@ export interface TelegramUpdateWorkerJournalPort {
|
|
|
1778
1979
|
removeCompleted: (updateIds: readonly number[]) => {
|
|
1779
1980
|
removedUpdateIds: readonly number[];
|
|
1780
1981
|
};
|
|
1982
|
+
/** No ID-only fallback is allowed for a guarded completion report. */
|
|
1983
|
+
removeCompletedExact?: (updateIds: readonly number[], expectedSources: readonly TelegramUpdateJournalEntryDigest[],
|
|
1984
|
+
completions?: readonly TelegramUpdateJournalSourceCompletion[]) => {
|
|
1985
|
+
removedUpdateIds: readonly number[];
|
|
1986
|
+
sourceCompletions?: readonly TelegramUpdateJournalSourceCompletion[];
|
|
1987
|
+
};
|
|
1988
|
+
inspectSourceCompletion?: (expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
export type TelegramQueueSourceCompletion = TelegramDeferredSourceEvidence & { completionSha256: string };
|
|
1992
|
+
|
|
1993
|
+
function normalizeTelegramQueueSourceCompletions(value: unknown, sourceUpdateIds: ReadonlySet<number>, journalBindingKey: string, full = true): TelegramQueueSourceCompletion[] {
|
|
1994
|
+
if (!Array.isArray(value) || (full ? value.length !== sourceUpdateIds.size : value.length > sourceUpdateIds.size) || value.length === 0) throw new Error("Telegram scoped queue completion requires full source coverage.");
|
|
1995
|
+
const seen = new Set<number>(), scopes = new Set<string>();
|
|
1996
|
+
return value.map(source => {
|
|
1997
|
+
if (!Number.isSafeInteger(source?.updateId) || !sourceUpdateIds.has(source.updateId) || seen.has(source.updateId)) {
|
|
1998
|
+
throw new Error("Telegram scoped queue completion has invalid source membership.");
|
|
1999
|
+
}
|
|
2000
|
+
const normalized = validateTelegramUpdateAdmissionOutcome({ kind: "complete", expectedSource: source }, source.updateId, sourceUpdateIds);
|
|
2001
|
+
if (normalized.kind !== "complete" || !normalized.expectedSource?.completionSha256 ||
|
|
2002
|
+
normalized.expectedSource.journalBindingKey !== journalBindingKey || scopes.has(normalized.expectedSource.completionSha256)) {
|
|
2003
|
+
throw new Error("Telegram scoped queue completion has invalid source scope.");
|
|
2004
|
+
}
|
|
2005
|
+
seen.add(source.updateId); scopes.add(normalized.expectedSource.completionSha256);
|
|
2006
|
+
return { ...normalized.expectedSource, completionSha256: normalized.expectedSource.completionSha256 };
|
|
2007
|
+
}).sort((a, b) => a.updateId - b.updateId);
|
|
1781
2008
|
}
|
|
1782
2009
|
|
|
1783
2010
|
export interface TelegramUpdateRetryPolicy {
|
|
@@ -1806,12 +2033,19 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
|
|
|
1806
2033
|
hasAuthority: (ctx: TContext) => boolean;
|
|
1807
2034
|
getJournalBindingKey?: () => string | undefined;
|
|
1808
2035
|
getRecipientBindingKey?: () => string | undefined;
|
|
2036
|
+
/** True selects legacy historical review/spending; retain protects unsupported originals without disposition authority. */
|
|
2037
|
+
shouldReviewHistoricalInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | "retain" | Promise<boolean | "retain">;
|
|
2038
|
+
/** New-world restart: spend previous-process routing inputs (classified or armed) without delivery, copy or completion observers. Interrupted private abandonment keeps its own exact recovery. */
|
|
2039
|
+
spendHistoricalInput?: boolean;
|
|
2040
|
+
/** Holds protected live or retry sources before execution; never cancels or disposes them. */
|
|
2041
|
+
shouldHoldPendingInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | Promise<boolean>;
|
|
1809
2042
|
getQueueOwnerIdentity?: (
|
|
1810
2043
|
ctx: TContext,
|
|
1811
2044
|
) => TelegramUpdateJournalQueueOwnerIdentity;
|
|
1812
2045
|
isContextCurrent?: (ctx: TContext) => boolean;
|
|
1813
2046
|
createAbortController?: () => AbortController;
|
|
1814
2047
|
getNowMs?: () => number;
|
|
2048
|
+
expireRoutingInput?: (source: TelegramRoutingInputExpirySource, ctx: TContext, signal: AbortSignal) => Promise<void> | void;
|
|
1815
2049
|
retryPolicy?: Partial<TelegramUpdateRetryPolicy>;
|
|
1816
2050
|
classifyExecutionFailure?: (
|
|
1817
2051
|
error: unknown,
|
|
@@ -1822,11 +2056,18 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
|
|
|
1822
2056
|
batchSize?: number;
|
|
1823
2057
|
yieldToEventLoop?: () => Promise<void>;
|
|
1824
2058
|
onStateChange?: (state: TelegramUpdateWorkerStateSnapshot) => void;
|
|
2059
|
+
/** One nonblocking hint after a quiescent validated startup projection, never execution or deletion authority. */
|
|
2060
|
+
onHeldSourcesPrepared?: (input: TelegramHeldSourcePreparation<TContext>) => Promise<void> | void;
|
|
2061
|
+
/** Prepared readiness barrier after durable queue admission; never authorizes replay or source removal. */
|
|
2062
|
+
beforeQueueReceiptPublished?: (receipt: TelegramQueueAdmissionReceiptLike, queueOwner: TelegramUpdateJournalQueueOwner,
|
|
2063
|
+
ctx: TContext, isCurrent: () => boolean) => Promise<void | readonly TelegramQueueSourceCompletion[]> | void | readonly TelegramQueueSourceCompletion[];
|
|
2064
|
+
/** Post-ACK hint only; it must not request Pi dispatch or inherit a Workspace admission lease. */
|
|
2065
|
+
onQueueReceiptCompleted?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext) => void;
|
|
1825
2066
|
onQueueReceiptCommitted?: (
|
|
1826
2067
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1827
2068
|
ctx: TContext,
|
|
1828
2069
|
) => void;
|
|
1829
|
-
onUpdateCompleted?: (updateId: number, ctx: TContext) => void;
|
|
2070
|
+
onUpdateCompleted?: (updateId: number, ctx: TContext, journalBindingKey?: string) => void;
|
|
1830
2071
|
recordRuntimeEvent?: (
|
|
1831
2072
|
category: string,
|
|
1832
2073
|
error: unknown,
|
|
@@ -1847,6 +2088,19 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
|
|
|
1847
2088
|
outcome: TelegramUpdateAdmissionOutcome;
|
|
1848
2089
|
signal: AbortSignal;
|
|
1849
2090
|
}) => void;
|
|
2091
|
+
armRoutingInput?: (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }) => TelegramUpdateJournalRoutingInput | undefined;
|
|
2092
|
+
selectRoutingInput?: (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }) => boolean;
|
|
2093
|
+
supportsDeferredAbandonment?: (input: { updateId: number; signal: AbortSignal; journalBindingKey: string }) => boolean;
|
|
2094
|
+
inspectAbandoning?: (input: TelegramDeferredAbandonmentRecoveryRequest & { signal: AbortSignal }) =>
|
|
2095
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
2096
|
+
inspectHistorical?: (input: TelegramDeferredAbandonmentRecoveryRequest & { signal: AbortSignal }) =>
|
|
2097
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
2098
|
+
inspectDeferredSource?: (input: { updateId: number; signal: AbortSignal }) => TelegramDeferredSourceEvidence | undefined;
|
|
2099
|
+
isHistoricalSource?: (input: { updateId: number; signal: AbortSignal; matchesOriginal?: TelegramHistoricalInputPredicate }) => boolean;
|
|
2100
|
+
abandonDeferred?: (input: TelegramDeferredUpdateAbandonmentAuthority & {
|
|
2101
|
+
updateId: number;
|
|
2102
|
+
signal: AbortSignal;
|
|
2103
|
+
}) => TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
1850
2104
|
settleCustodied: (input: {
|
|
1851
2105
|
updateId: number;
|
|
1852
2106
|
result: TelegramCustodiedExecutionResult;
|
|
@@ -1858,17 +2112,22 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
|
|
|
1858
2112
|
getQueueReceiptOwner: (
|
|
1859
2113
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1860
2114
|
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
2115
|
+
/** Completion-only owner observation; issued attempts are never dispatch readiness. */
|
|
2116
|
+
getQueueReceiptSettlementOwner?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext,
|
|
2117
|
+
reason: TelegramQueueReceiptCompletionReason) => TelegramUpdateJournalQueueOwner | undefined;
|
|
1861
2118
|
completeQueueReceipts: (input: {
|
|
1862
2119
|
receipts: readonly TelegramQueueAdmissionReceiptLike[];
|
|
1863
2120
|
ctx: TContext;
|
|
1864
2121
|
reason: TelegramQueueReceiptCompletionReason;
|
|
2122
|
+
/** Prepared immutable scopes for every source; the exact receipt owner still authorizes disposal. */
|
|
2123
|
+
sourceCompletions?: readonly TelegramQueueSourceCompletion[];
|
|
1865
2124
|
}) => boolean;
|
|
1866
2125
|
stop: () => Promise<void>;
|
|
1867
2126
|
waitForDrain: () => Promise<void>;
|
|
1868
2127
|
getState: () => TelegramUpdateWorkerStateSnapshot;
|
|
1869
2128
|
}
|
|
1870
2129
|
|
|
1871
|
-
|
|
2130
|
+
class TelegramUpdateAdmissionOutcomeError extends Error {
|
|
1872
2131
|
constructor(message: string) {
|
|
1873
2132
|
super(message);
|
|
1874
2133
|
this.name = "TelegramUpdateAdmissionOutcomeError";
|
|
@@ -1880,9 +2139,11 @@ interface TelegramUpdateWorkerOwner<TContext> {
|
|
|
1880
2139
|
ctx: TContext;
|
|
1881
2140
|
controller: AbortController;
|
|
1882
2141
|
queueOwnerIdentity: TelegramUpdateJournalQueueOwnerIdentity;
|
|
2142
|
+
startupUpdateIds?: ReadonlySet<number>;
|
|
2143
|
+
heldSourcesPreparedIssued?: true;
|
|
1883
2144
|
}
|
|
1884
2145
|
|
|
1885
|
-
type TelegramUpdateWorkerClaim = "deferred" | "queued";
|
|
2146
|
+
type TelegramUpdateWorkerClaim = "deferred" | "queued" | "abandoning" | "historical" | "retained";
|
|
1886
2147
|
type TelegramUpdateWorkerDrainResult = "idle" | "blocked" | "aborted";
|
|
1887
2148
|
type TelegramUpdateWorkerExecutionSettlement =
|
|
1888
2149
|
| { ok: true; outcome: TelegramUpdateAdmissionOutcome; custodied?: false }
|
|
@@ -1899,16 +2160,6 @@ function getTelegramUpdateWorkerStateSnapshot(
|
|
|
1899
2160
|
return { ...state };
|
|
1900
2161
|
}
|
|
1901
2162
|
|
|
1902
|
-
function isTelegramUpdateAdmissionRecord(
|
|
1903
|
-
value: unknown,
|
|
1904
|
-
): value is Record<string, unknown> {
|
|
1905
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
function isTelegramUpdateAdmissionString(value: unknown): value is string {
|
|
1909
|
-
return typeof value === "string" && value.length > 0;
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
2163
|
function validateTelegramUpdateAdmissionOutcome(
|
|
1913
2164
|
value: unknown,
|
|
1914
2165
|
currentUpdateId: number,
|
|
@@ -1919,8 +2170,23 @@ function validateTelegramUpdateAdmissionOutcome(
|
|
|
1919
2170
|
`Telegram update ${currentUpdateId} returned no admission outcome.`,
|
|
1920
2171
|
);
|
|
1921
2172
|
}
|
|
1922
|
-
if (value.kind === "complete")
|
|
1923
|
-
|
|
2173
|
+
if (value.kind === "complete") {
|
|
2174
|
+
if (value.expectedSource === undefined) return { kind: "complete" };
|
|
2175
|
+
const source = value.expectedSource;
|
|
2176
|
+
if (!isTelegramUpdateAdmissionRecord(source) || Object.keys(source).some(key => !["journalBindingKey", "updateId", "sourceSha256", "completionSha256"].includes(key)) ||
|
|
2177
|
+
source.updateId !== currentUpdateId || !isTelegramUpdateAdmissionString(source.journalBindingKey) ||
|
|
2178
|
+
typeof source.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sourceSha256) ||
|
|
2179
|
+
source.completionSha256 !== undefined && (typeof source.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.completionSha256))) {
|
|
2180
|
+
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${currentUpdateId} returned an invalid completion source.`);
|
|
2181
|
+
}
|
|
2182
|
+
return { kind: "complete", expectedSource: { journalBindingKey: source.journalBindingKey,
|
|
2183
|
+
updateId: currentUpdateId, sourceSha256: source.sourceSha256,
|
|
2184
|
+
...(source.completionSha256 !== undefined ? { completionSha256: source.completionSha256 } : {}) } };
|
|
2185
|
+
}
|
|
2186
|
+
if (value.kind === "deferred") {
|
|
2187
|
+
if (value.routingReview !== undefined && value.routingReview !== true) throw new TelegramUpdateAdmissionOutcomeError("Invalid historical review outcome.");
|
|
2188
|
+
return { kind: "deferred", ...(value.routingReview === true ? { routingReview: true } : {}) };
|
|
2189
|
+
}
|
|
1924
2190
|
if (value.kind === "queued") {
|
|
1925
2191
|
if (
|
|
1926
2192
|
(value.queueKind !== "prompt" && value.queueKind !== "control") ||
|
|
@@ -2072,12 +2338,23 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2072
2338
|
);
|
|
2073
2339
|
}
|
|
2074
2340
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
2341
|
+
const onHeldSourcesPrepared = deps.onHeldSourcesPrepared;
|
|
2342
|
+
const spendHistorical = deps.spendHistoricalInput === true;
|
|
2343
|
+
const routingJournal = deps.journal.routingInputs;
|
|
2344
|
+
const armRouting = routingJournal?.arm.bind(routingJournal);
|
|
2345
|
+
const selectRouting = routingJournal?.select.bind(routingJournal);
|
|
2346
|
+
const expireRouting = routingJournal?.expire.bind(routingJournal);
|
|
2347
|
+
const expireRoutingObserver = deps.expireRoutingInput;
|
|
2075
2348
|
const batchSize = deps.batchSize ?? TELEGRAM_UPDATE_WORKER_BATCH_SIZE;
|
|
2076
2349
|
if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
|
|
2077
2350
|
throw new Error("Telegram update worker batch size must be positive.");
|
|
2078
2351
|
}
|
|
2079
2352
|
const yieldToEventLoop = deps.yieldToEventLoop ??
|
|
2080
2353
|
(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
|
2354
|
+
const completeQueuedExact = deps.journal.completeQueuedExact?.bind(deps.journal);
|
|
2355
|
+
const completeExact = deps.journal.removeCompletedExact?.bind(deps.journal);
|
|
2356
|
+
const inspectCompletion = deps.journal.inspectSourceCompletion?.bind(deps.journal);
|
|
2357
|
+
const observeQueuedCompletion = deps.onQueueReceiptCompleted;
|
|
2081
2358
|
const fallbackQueueOwnerInstanceId = `worker-${randomUUID()}`;
|
|
2082
2359
|
const fallbackQueueOwnerProcessId = process.pid > 0 ? process.pid : 1;
|
|
2083
2360
|
const createAbortController =
|
|
@@ -2100,6 +2377,12 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2100
2377
|
sessionGeneration: generation,
|
|
2101
2378
|
},
|
|
2102
2379
|
);
|
|
2380
|
+
const isQueueOwnerIdentityCurrent = (expected: TelegramUpdateWorkerOwner<TContext>): boolean => {
|
|
2381
|
+
if (!deps.getQueueOwnerIdentity) return true;
|
|
2382
|
+
const live = deps.getQueueOwnerIdentity(expected.ctx), captured = expected.queueOwnerIdentity;
|
|
2383
|
+
return !!live && live.instanceId === captured.instanceId && live.processId === captured.processId &&
|
|
2384
|
+
live.processBirthId === captured.processBirthId && live.sessionGeneration === captured.sessionGeneration;
|
|
2385
|
+
};
|
|
2103
2386
|
const state: TelegramUpdateWorkerStateSnapshot = {
|
|
2104
2387
|
phase: "stopped",
|
|
2105
2388
|
generation: 0,
|
|
@@ -2113,6 +2396,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2113
2396
|
unsettledExecutionCount: 0,
|
|
2114
2397
|
};
|
|
2115
2398
|
const claims = new Map<number, TelegramUpdateWorkerClaim>();
|
|
2399
|
+
const deferredSources = new Map<number, {
|
|
2400
|
+
entry: TelegramUpdateWorkerJournalSnapshot["entries"][number];
|
|
2401
|
+
journalBindingKey: string;
|
|
2402
|
+
historical?: true;
|
|
2403
|
+
}>();
|
|
2116
2404
|
const unsettledExecutionsByUpdateId = new Map<
|
|
2117
2405
|
number,
|
|
2118
2406
|
Set<Promise<TelegramUpdateWorkerExecutionSettlement>>
|
|
@@ -2122,8 +2410,15 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2122
2410
|
{
|
|
2123
2411
|
receipt: TelegramQueueAdmissionReceiptLike;
|
|
2124
2412
|
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2413
|
+
sourceCompletions?: TelegramQueueSourceCompletion[];
|
|
2125
2414
|
}
|
|
2126
2415
|
>();
|
|
2416
|
+
const requiredScopedQueueReceipts = new Set<string>();
|
|
2417
|
+
const scopedQueueCompletionAttempts = new Map<string, {
|
|
2418
|
+
owner: TelegramUpdateWorkerOwner<TContext>; journalBindingKey: string; reason: TelegramQueueReceiptCompletionReason;
|
|
2419
|
+
receipts: { queueKind: "prompt" | "control"; receiptId: string; sourceUpdateIds: readonly number[]; queueOwner: TelegramUpdateJournalQueueOwner }[];
|
|
2420
|
+
completions: TelegramUpdateJournalSourceCompletion[];
|
|
2421
|
+
}>();
|
|
2127
2422
|
const unsettledExecutions =
|
|
2128
2423
|
new Set<Promise<TelegramUpdateWorkerExecutionSettlement>>();
|
|
2129
2424
|
let owner: TelegramUpdateWorkerOwner<TContext> | undefined;
|
|
@@ -2134,6 +2429,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2134
2429
|
let retryTimer: unknown;
|
|
2135
2430
|
let retryTimerAtMs: number | undefined;
|
|
2136
2431
|
let retryTimerToken: object | undefined;
|
|
2432
|
+
let routingTimer: unknown;
|
|
2433
|
+
let routingTimerToken: object | undefined;
|
|
2137
2434
|
let launchDrain: () => void = () => {};
|
|
2138
2435
|
|
|
2139
2436
|
const recordRuntimeEvent = (
|
|
@@ -2158,12 +2455,20 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2158
2455
|
const updateClaimCounts = (): void => {
|
|
2159
2456
|
let deferredClaimCount = 0;
|
|
2160
2457
|
let queuedClaimCount = 0;
|
|
2458
|
+
let abandoningClaimCount = 0;
|
|
2459
|
+
let historicalClaimCount = 0;
|
|
2161
2460
|
for (const claim of claims.values()) {
|
|
2162
2461
|
if (claim === "queued") queuedClaimCount += 1;
|
|
2163
2462
|
else deferredClaimCount += 1;
|
|
2463
|
+
if (claim === "abandoning") abandoningClaimCount += 1;
|
|
2464
|
+
if (claim === "historical" || claim === "retained") historicalClaimCount += 1;
|
|
2164
2465
|
}
|
|
2165
2466
|
state.deferredClaimCount = deferredClaimCount;
|
|
2166
2467
|
state.queuedClaimCount = queuedClaimCount;
|
|
2468
|
+
if (abandoningClaimCount > 0) state.abandoningClaimCount = abandoningClaimCount;
|
|
2469
|
+
else delete state.abandoningClaimCount;
|
|
2470
|
+
if (historicalClaimCount > 0) state.historicalClaimCount = historicalClaimCount;
|
|
2471
|
+
else delete state.historicalClaimCount;
|
|
2167
2472
|
};
|
|
2168
2473
|
|
|
2169
2474
|
const releaseDeferredClaims = (updateIds: readonly number[]): void => {
|
|
@@ -2171,6 +2476,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2171
2476
|
for (const updateId of updateIds) {
|
|
2172
2477
|
if (claims.get(updateId) !== "deferred") continue;
|
|
2173
2478
|
claims.delete(updateId);
|
|
2479
|
+
deferredSources.delete(updateId);
|
|
2174
2480
|
changed = true;
|
|
2175
2481
|
}
|
|
2176
2482
|
if (!changed) return;
|
|
@@ -2233,11 +2539,18 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2233
2539
|
: {}),
|
|
2234
2540
|
});
|
|
2235
2541
|
|
|
2236
|
-
const
|
|
2542
|
+
const inspectQueueReceipt = deps.journal.isQueueReceiptCurrent?.bind(deps.journal);
|
|
2543
|
+
const inspectQueuedSources = deps.journal.inspectQueuedReceipt?.bind(deps.journal);
|
|
2544
|
+
const prepareQueueReceipt = deps.beforeQueueReceiptPublished;
|
|
2545
|
+
const pendingQueuePublications = new Map<string, { receipt: TelegramQueueAdmissionReceiptLike;
|
|
2546
|
+
queueOwner: TelegramUpdateJournalQueueOwner; owner: TelegramUpdateWorkerOwner<TContext>; task: Promise<void> }>();
|
|
2547
|
+
const publishCommittedQueueReceipt = async (
|
|
2237
2548
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2238
2549
|
queueOwner: TelegramUpdateJournalQueueOwner,
|
|
2239
|
-
|
|
2240
|
-
): boolean => {
|
|
2550
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2551
|
+
): Promise<boolean> => {
|
|
2552
|
+
const ctx = expectedOwner.ctx;
|
|
2553
|
+
let sourceCompletions: TelegramQueueSourceCompletion[] | undefined;
|
|
2241
2554
|
const normalized = bindQueueReceiptToJournal(receipt);
|
|
2242
2555
|
const existing = committedQueueReceipts.get(receipt.receiptId);
|
|
2243
2556
|
if (existing) {
|
|
@@ -2257,29 +2570,86 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2257
2570
|
}
|
|
2258
2571
|
return false;
|
|
2259
2572
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2573
|
+
if (prepareQueueReceipt) {
|
|
2574
|
+
const pending = pendingQueuePublications.get(normalized.receiptId);
|
|
2575
|
+
if (pending) {
|
|
2576
|
+
if (pending.owner !== expectedOwner || !areTelegramQueueAdmissionReceiptsEqual(pending.receipt, normalized) ||
|
|
2577
|
+
!areTelegramUpdateJournalQueueOwnersEqual(pending.queueOwner, queueOwner)) {
|
|
2578
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication has conflicting in-flight authority.");
|
|
2579
|
+
}
|
|
2580
|
+
await pending.task;
|
|
2581
|
+
return false;
|
|
2582
|
+
}
|
|
2583
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
2584
|
+
const current = (): boolean => owner === expectedOwner && !expectedOwner.controller.signal.aborted && deps.hasAuthority(ctx) && deps.isContextCurrent?.(ctx) !== false &&
|
|
2585
|
+
isQueueOwnerIdentityCurrent(expectedOwner) && deps.getJournalBindingKey?.() === journalBindingKey;
|
|
2586
|
+
if (!journalBindingKey || !current() || !inspectQueueReceipt ||
|
|
2587
|
+
inspectQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }) !== true || !current()) {
|
|
2588
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication requires exact current receipt inspection.");
|
|
2589
|
+
}
|
|
2590
|
+
const task = Promise.resolve().then(async () => {
|
|
2591
|
+
if (!current()) throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication authority ended before acceptance.");
|
|
2592
|
+
const prepared = await prepareQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }, ctx, current);
|
|
2593
|
+
if (prepared !== undefined) {
|
|
2594
|
+
sourceCompletions = normalizeTelegramQueueSourceCompletions(prepared, new Set(normalized.sourceUpdateIds), journalBindingKey, false);
|
|
2595
|
+
if (!completeQueuedExact || !inspectCompletion) throw new Error("Telegram scoped queue terminal capabilities are unavailable.");
|
|
2596
|
+
if (sourceCompletions.length !== normalized.sourceUpdateIds.length) {
|
|
2597
|
+
if (!current() || !inspectQueuedSources) throw new Error("Telegram partial queue scopes require strict current whole-receipt origin inspection.");
|
|
2598
|
+
const expected = { queueKind: normalized.queueKind, receiptId: normalized.receiptId,
|
|
2599
|
+
sourceUpdateIds: [...normalized.sourceUpdateIds], queueOwner: { ...queueOwner } };
|
|
2600
|
+
const proof = inspectQueuedSources(structuredClone(expected));
|
|
2601
|
+
if (!current() || !proof || !isDeepStrictEqual(proof.receipt, expected) ||
|
|
2602
|
+
!Array.isArray(proof.sources) || proof.sources.length !== expected.sourceUpdateIds.length ||
|
|
2603
|
+
proof.sources.some((source, index) => source.updateId !== expected.sourceUpdateIds[index] || typeof source.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sourceSha256)) ||
|
|
2604
|
+
sourceCompletions.some(scope => proof.sources.find(source => source.updateId === scope.updateId)?.sourceSha256 !== scope.sourceSha256)) {
|
|
2605
|
+
throw new Error("Telegram partial queue scope origin was not confirmed under whole-receipt authority.");
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
for (const { journalBindingKey: _binding, ...completion } of sourceCompletions) {
|
|
2609
|
+
if (!current() || inspectCompletion({ ...completion }) !== undefined || !current()) {
|
|
2610
|
+
throw new Error("Telegram queued source completion authority is contradictory.");
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
if (!current() || inspectQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }) !== true || !current()) {
|
|
2615
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication authority changed after acceptance.");
|
|
2616
|
+
}
|
|
2617
|
+
publishReady();
|
|
2618
|
+
}).finally(() => { if (pendingQueuePublications.get(normalized.receiptId)?.task === task) pendingQueuePublications.delete(normalized.receiptId); });
|
|
2619
|
+
pendingQueuePublications.set(normalized.receiptId, { receipt: normalizeQueueReceipt(normalized), queueOwner: { ...queueOwner }, owner: expectedOwner, task });
|
|
2620
|
+
await task;
|
|
2621
|
+
return true;
|
|
2271
2622
|
}
|
|
2623
|
+
publishReady();
|
|
2272
2624
|
return true;
|
|
2625
|
+
|
|
2626
|
+
function publishReady(): void {
|
|
2627
|
+
committedQueueReceipts.set(receipt.receiptId, {
|
|
2628
|
+
receipt: normalized,
|
|
2629
|
+
queueOwner: { ...queueOwner },
|
|
2630
|
+
...(sourceCompletions ? { sourceCompletions: sourceCompletions.map(value => ({ ...value })) } : {}),
|
|
2631
|
+
});
|
|
2632
|
+
if (sourceCompletions) requiredScopedQueueReceipts.add(receipt.receiptId);
|
|
2633
|
+
try {
|
|
2634
|
+
deps.onQueueReceiptCommitted?.(normalizeQueueReceipt(normalized), ctx);
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
recordRuntimeEvent(error, {
|
|
2637
|
+
phase: "queue-receipt-observer",
|
|
2638
|
+
receiptId: normalized.receiptId,
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2273
2642
|
};
|
|
2274
2643
|
|
|
2275
2644
|
const getCurrentQueueReceipt = (receipt: TelegramQueueAdmissionReceiptLike) => {
|
|
2645
|
+
if (scopedQueueCompletionAttempts.has(receipt.receiptId)) return undefined;
|
|
2276
2646
|
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
2277
2647
|
if (!committed || !areTelegramQueueAdmissionReceiptsEqual(
|
|
2278
2648
|
committed.receipt, normalizeQueueReceipt(receipt),
|
|
2279
2649
|
)) return undefined;
|
|
2280
2650
|
try {
|
|
2281
|
-
if (
|
|
2282
|
-
|
|
2651
|
+
if (inspectQueueReceipt &&
|
|
2652
|
+
inspectQueueReceipt(normalizeQueueReceipt(committed.receipt), { ...committed.queueOwner }) !== true) {
|
|
2283
2653
|
return undefined;
|
|
2284
2654
|
}
|
|
2285
2655
|
} catch (error) {
|
|
@@ -2327,10 +2697,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2327
2697
|
}, Math.max(0, nextRetryAtMs - getNowMs()));
|
|
2328
2698
|
};
|
|
2329
2699
|
|
|
2330
|
-
const refreshJournalState = (
|
|
2700
|
+
const refreshJournalState = async (
|
|
2331
2701
|
snapshot: TelegramUpdateWorkerJournalSnapshot,
|
|
2332
2702
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2333
|
-
): number | undefined => {
|
|
2703
|
+
): Promise<number | undefined> => {
|
|
2334
2704
|
// Validate the whole snapshot before reconstructing any queue authority.
|
|
2335
2705
|
if ((snapshot.version !== TELEGRAM_UPDATE_JOURNAL_VERSION &&
|
|
2336
2706
|
snapshot.version !== TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION &&
|
|
@@ -2367,7 +2737,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2367
2737
|
oldestAdmittedAtMs === undefined
|
|
2368
2738
|
? entry.admittedAtMs
|
|
2369
2739
|
: Math.min(oldestAdmittedAtMs, entry.admittedAtMs);
|
|
2370
|
-
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined) {
|
|
2740
|
+
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined && !claims.has(entry.updateId)) {
|
|
2371
2741
|
scheduledRetryAtMs =
|
|
2372
2742
|
scheduledRetryAtMs === undefined
|
|
2373
2743
|
? entry.nextRetryAtMs
|
|
@@ -2449,21 +2819,25 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2449
2819
|
}
|
|
2450
2820
|
for (const [updateId, claim] of claims) {
|
|
2451
2821
|
if (
|
|
2452
|
-
!availableUpdateIds.has(updateId) ||
|
|
2822
|
+
(claim !== "abandoning" && !availableUpdateIds.has(updateId)) ||
|
|
2453
2823
|
(claim === "queued" && !locallyOwnedQueuedUpdateIds.has(updateId))
|
|
2454
2824
|
) {
|
|
2455
2825
|
claims.delete(updateId);
|
|
2456
2826
|
}
|
|
2457
2827
|
}
|
|
2828
|
+
for (const updateId of deferredSources.keys()) {
|
|
2829
|
+
const claim = claims.get(updateId);
|
|
2830
|
+
if (claim !== "deferred" && claim !== "abandoning" && claim !== "historical" && claim !== "retained") deferredSources.delete(updateId);
|
|
2831
|
+
}
|
|
2458
2832
|
for (const [receiptId, receipt] of queuedReceiptEntries) {
|
|
2459
|
-
publishCommittedQueueReceipt(
|
|
2833
|
+
await publishCommittedQueueReceipt(
|
|
2460
2834
|
{
|
|
2461
2835
|
queueKind: receipt.queueKind,
|
|
2462
2836
|
receiptId,
|
|
2463
2837
|
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
2464
2838
|
},
|
|
2465
2839
|
receipt.queueOwner,
|
|
2466
|
-
expectedOwner
|
|
2840
|
+
expectedOwner,
|
|
2467
2841
|
);
|
|
2468
2842
|
}
|
|
2469
2843
|
for (const [receiptId] of committedQueueReceipts) {
|
|
@@ -2598,10 +2972,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2598
2972
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2599
2973
|
currentUpdateId: number,
|
|
2600
2974
|
outcome: TelegramQueuedUpdateAdmissionOutcome,
|
|
2601
|
-
):
|
|
2602
|
-
| "
|
|
2603
|
-
| "duplicate"
|
|
2604
|
-
| Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
|
|
2975
|
+
): "committed" | "duplicate" | Exclude<TelegramUpdateWorkerDrainResult, "idle"> |
|
|
2976
|
+
Promise<"committed" | "duplicate" | Exclude<TelegramUpdateWorkerDrainResult, "idle">> => {
|
|
2605
2977
|
const normalized = normalizeQueueReceipt(outcome);
|
|
2606
2978
|
const existing = committedQueueReceipts.get(normalized.receiptId);
|
|
2607
2979
|
if (existing) {
|
|
@@ -2674,21 +3046,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2674
3046
|
for (const sourceUpdateId of normalized.sourceUpdateIds) {
|
|
2675
3047
|
claims.set(sourceUpdateId, "queued");
|
|
2676
3048
|
}
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
normalized,
|
|
2680
|
-
queueOwner,
|
|
2681
|
-
expectedOwner.ctx,
|
|
2682
|
-
);
|
|
2683
|
-
} catch (error) {
|
|
2684
|
-
return blockWithFailure(
|
|
2685
|
-
"invalid-outcome",
|
|
2686
|
-
"queue-receipt-publish",
|
|
2687
|
-
error,
|
|
2688
|
-
currentUpdateId,
|
|
2689
|
-
);
|
|
2690
|
-
}
|
|
2691
|
-
return "committed";
|
|
3049
|
+
return publishCommittedQueueReceipt(normalized, queueOwner, expectedOwner).then(() => "committed" as const,
|
|
3050
|
+
error => blockWithFailure("invalid-outcome", "queue-receipt-publish", error, currentUpdateId));
|
|
2692
3051
|
};
|
|
2693
3052
|
|
|
2694
3053
|
const persistExecutionFailure = (
|
|
@@ -2774,16 +3133,45 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2774
3133
|
|
|
2775
3134
|
const commitCompletedBatch = (
|
|
2776
3135
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2777
|
-
|
|
3136
|
+
completions: readonly { updateId: number; expectedSource?: TelegramDeferredSourceEvidence; spent?: true }[],
|
|
2778
3137
|
): Exclude<TelegramUpdateWorkerDrainResult, "idle"> | undefined => {
|
|
3138
|
+
const updateIds = completions.map(value => value.updateId);
|
|
2779
3139
|
if (updateIds.length === 0) return undefined;
|
|
2780
3140
|
const commitAuthority = checkAuthority(
|
|
2781
3141
|
expectedOwner,
|
|
2782
3142
|
updateIds[updateIds.length - 1],
|
|
2783
3143
|
);
|
|
2784
3144
|
if (commitAuthority) return commitAuthority;
|
|
3145
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
2785
3146
|
try {
|
|
2786
|
-
const
|
|
3147
|
+
const expectedSources = completions.flatMap(value => value.expectedSource ? [value.expectedSource] : []);
|
|
3148
|
+
if (expectedSources.some(value => value.journalBindingKey !== journalBindingKey) ||
|
|
3149
|
+
(expectedSources.length > 0 && (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner)))) {
|
|
3150
|
+
throw new Error("Telegram guarded completion source authority changed.");
|
|
3151
|
+
}
|
|
3152
|
+
const scoped = expectedSources.flatMap(({ updateId, sourceSha256, completionSha256 }) => completionSha256 !== undefined
|
|
3153
|
+
? [{ updateId, sourceSha256, completionSha256 }] : []).sort((a, b) => a.updateId - b.updateId);
|
|
3154
|
+
if (scoped.length > 0 && !inspectCompletion) throw new Error("Telegram source completion inspection is unavailable.");
|
|
3155
|
+
let removed: { removedUpdateIds: readonly number[]; sourceCompletions?: readonly TelegramUpdateJournalSourceCompletion[] };
|
|
3156
|
+
if (expectedSources.length > 0) {
|
|
3157
|
+
if (!completeExact) throw new Error("Telegram exact source completion is unavailable.");
|
|
3158
|
+
removed = completeExact(updateIds, expectedSources.map(({ updateId, sourceSha256 }) => ({ updateId, sourceSha256 })),
|
|
3159
|
+
scoped.length > 0 ? scoped.map(completion => ({ ...completion })) : undefined);
|
|
3160
|
+
} else removed = deps.journal.removeCompleted(updateIds);
|
|
3161
|
+
if (scoped.length > 0) {
|
|
3162
|
+
const afterCommit = checkAuthority(expectedOwner, updateIds.at(-1));
|
|
3163
|
+
if (afterCommit) return afterCommit;
|
|
3164
|
+
if (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
3165
|
+
deps.getJournalBindingKey?.() !== journalBindingKey) throw new Error("Telegram source completion ACK authority changed.");
|
|
3166
|
+
if (!isDeepStrictEqual(removed.sourceCompletions, scoped)) throw new Error("Telegram source completion ACK was not confirmed.");
|
|
3167
|
+
for (const completion of scoped) {
|
|
3168
|
+
if (!isDeepStrictEqual(inspectCompletion!({ ...completion }), completion)) throw new Error("Telegram source completion ACK was not retained.");
|
|
3169
|
+
const afterRead = checkAuthority(expectedOwner, completion.updateId);
|
|
3170
|
+
if (afterRead) return afterRead;
|
|
3171
|
+
if (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
3172
|
+
deps.getJournalBindingKey?.() !== journalBindingKey) throw new Error("Telegram source completion ACK authority changed.");
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
2787
3175
|
const removedIds = new Set(removed.removedUpdateIds);
|
|
2788
3176
|
if (updateIds.some((updateId) => !removedIds.has(updateId))) {
|
|
2789
3177
|
throw new Error(
|
|
@@ -2799,12 +3187,15 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2799
3187
|
);
|
|
2800
3188
|
}
|
|
2801
3189
|
const completedAtMs = getNowMs();
|
|
2802
|
-
for (const updateId of
|
|
3190
|
+
for (const { updateId, spent } of completions) {
|
|
2803
3191
|
claims.delete(updateId);
|
|
3192
|
+
deferredSources.delete(updateId);
|
|
3193
|
+
// Spending is disposition, not completion: no task, cleanup or session observer runs.
|
|
3194
|
+
if (spent) continue;
|
|
2804
3195
|
state.lastCompletedUpdateId = updateId;
|
|
2805
3196
|
state.lastCompletedAtMs = completedAtMs;
|
|
2806
3197
|
try {
|
|
2807
|
-
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx);
|
|
3198
|
+
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx, journalBindingKey);
|
|
2808
3199
|
} catch (error) {
|
|
2809
3200
|
recordRuntimeEvent(error, {
|
|
2810
3201
|
phase: "update-completion-observer",
|
|
@@ -2815,6 +3206,82 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2815
3206
|
return undefined;
|
|
2816
3207
|
};
|
|
2817
3208
|
|
|
3209
|
+
const clearRoutingTimer = (): void => {
|
|
3210
|
+
if (routingTimer !== undefined) cancelRetry(routingTimer);
|
|
3211
|
+
routingTimer = undefined; routingTimerToken = undefined;
|
|
3212
|
+
};
|
|
3213
|
+
const reconcileRoutingInputs = async (snapshot: TelegramUpdateWorkerJournalSnapshot, expectedOwner: TelegramUpdateWorkerOwner<TContext>): Promise<boolean> => {
|
|
3214
|
+
clearRoutingTimer();
|
|
3215
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) || !expireRouting || !expireRoutingObserver) return false;
|
|
3216
|
+
let nextAtMs: number | undefined, changed = false;
|
|
3217
|
+
const binding = deps.getJournalBindingKey?.(), snapshotIds = new Set(snapshot.entries.map(entry => entry.updateId));
|
|
3218
|
+
// Lost removal ACKs reconcile only the exact held original through the retained copy/tombstone.
|
|
3219
|
+
const candidates = [...snapshot.entries, ...[...deferredSources.values()]
|
|
3220
|
+
.filter(source => source.journalBindingKey === binding && !snapshotIds.has(source.entry.updateId))
|
|
3221
|
+
.map(source => source.entry)];
|
|
3222
|
+
for (const entry of candidates) {
|
|
3223
|
+
const lifetime = entry.routingInput;
|
|
3224
|
+
if (claims.get(entry.updateId) === "retained" || (expectedOwner.startupUpdateIds?.has(entry.updateId) &&
|
|
3225
|
+
!claims.has(entry.updateId) && deps.shouldReviewHistoricalInput)) continue;
|
|
3226
|
+
if (!lifetime || lifetime.phase !== "waiting" || entry.state !== "pending" || !binding || unsettledExecutionsByUpdateId.get(entry.updateId)?.size) continue;
|
|
3227
|
+
if (getNowMs() < lifetime.expiresAtMs) { nextAtMs = Math.min(nextAtMs ?? Infinity, lifetime.expiresAtMs); continue; }
|
|
3228
|
+
const existing = deferredSources.get(entry.updateId);
|
|
3229
|
+
if (existing && !isDeepStrictEqual(existing.entry, entry)) continue;
|
|
3230
|
+
const source = existing ?? { entry: structuredClone(entry), journalBindingKey: binding, historical: true as const };
|
|
3231
|
+
deferredSources.set(entry.updateId, source);
|
|
3232
|
+
if (!claims.has(entry.updateId)) claims.set(entry.updateId, "historical");
|
|
3233
|
+
const current = () => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
3234
|
+
deps.isContextCurrent?.(expectedOwner.ctx) !== false && deps.getJournalBindingKey?.() === binding &&
|
|
3235
|
+
deps.hasAuthority(expectedOwner.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) && deferredSources.get(entry.updateId) === source &&
|
|
3236
|
+
isDeepStrictEqual(source.entry, entry) && claims.get(entry.updateId) !== "queued";
|
|
3237
|
+
let committed: TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
3238
|
+
try {
|
|
3239
|
+
if (current()) await expireRoutingObserver({ original: structuredClone(entry), journalBindingKey: binding, isCurrent: current,
|
|
3240
|
+
expire() {
|
|
3241
|
+
if (!current()) return undefined;
|
|
3242
|
+
if (committed) return { ...committed, duplicate: true };
|
|
3243
|
+
claims.set(entry.updateId, "abandoning");
|
|
3244
|
+
committed = expireRouting!({ entry: source.entry, journalBindingKey: binding, operatorUserId: lifetime.operatorUserId, isCurrent: current });
|
|
3245
|
+
claims.delete(entry.updateId); deferredSources.delete(entry.updateId);
|
|
3246
|
+
state.journalEntryCount = committed.entryCount; state.journalSerializedBytes = committed.serializedBytes;
|
|
3247
|
+
updateClaimCounts(); notifyStateChange(); changed = true;
|
|
3248
|
+
return committed;
|
|
3249
|
+
} }, expectedOwner.ctx, expectedOwner.controller.signal);
|
|
3250
|
+
} catch (error) { recordRuntimeEvent(error, { phase: "routing-input-expiry", updateId: entry.updateId }); }
|
|
3251
|
+
if (!committed) nextAtMs = Math.min(nextAtMs ?? Infinity, getNowMs() + 60_000);
|
|
3252
|
+
}
|
|
3253
|
+
if (nextAtMs !== undefined && owner === expectedOwner && !expectedOwner.controller.signal.aborted) {
|
|
3254
|
+
const token = {}; routingTimerToken = token;
|
|
3255
|
+
routingTimer = scheduleRetry(() => {
|
|
3256
|
+
if (routingTimerToken !== token || owner !== expectedOwner || expectedOwner.controller.signal.aborted) return;
|
|
3257
|
+
routingTimer = undefined; routingTimerToken = undefined; pendingSignal = true; launchDrain();
|
|
3258
|
+
}, Math.max(1, nextAtMs - getNowMs()));
|
|
3259
|
+
}
|
|
3260
|
+
return changed;
|
|
3261
|
+
};
|
|
3262
|
+
const mutateRoutingSource = (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }, select: boolean) => {
|
|
3263
|
+
const expectedOwner = owner, ids = [...input.sourceUpdateIds];
|
|
3264
|
+
if (!ids.length || !ids.includes(input.updateId) || new Set(ids).size !== ids.length || ids.some(id => !Number.isSafeInteger(id) || id < 0)) return undefined;
|
|
3265
|
+
const sources = ids.map(id => deferredSources.get(id)), binding = sources[0]?.journalBindingKey;
|
|
3266
|
+
const current = () => !!expectedOwner && owner === expectedOwner && expectedOwner.controller.signal === input.signal && !input.signal.aborted &&
|
|
3267
|
+
!!binding && sources.every((source, index) => !!source && deferredSources.get(ids[index]!) === source && !source.historical &&
|
|
3268
|
+
source.journalBindingKey === binding && claims.get(ids[index]!) !== "queued" && claims.get(ids[index]!) !== "abandoning") &&
|
|
3269
|
+
deps.getJournalBindingKey?.() === binding && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3270
|
+
deps.hasAuthority(expectedOwner.ctx) && isQueueOwnerIdentityCurrent(expectedOwner);
|
|
3271
|
+
if (!current() || !armRouting || !selectRouting || !expireRoutingObserver) return undefined;
|
|
3272
|
+
const authority = { journalBindingKey: binding!, entries: sources.map(source => structuredClone(source!.entry)), operatorUserId: input.operatorUserId, isCurrent: current };
|
|
3273
|
+
const result = select ? selectRouting(authority) : { issued: false, entries: armRouting({ ...authority, publishedAtMs: getNowMs() }) };
|
|
3274
|
+
if (result.entries.length !== ids.length || result.entries.some((entry, index) => entry.updateId !== ids[index] || !entry.routingInput) || !current()) {
|
|
3275
|
+
throw new Error("Telegram routing input lifetime ACK was not confirmed.");
|
|
3276
|
+
}
|
|
3277
|
+
const retained = deps.journal.read().entries;
|
|
3278
|
+
if (result.entries.some(entry => !isDeepStrictEqual(retained.find(value => value.updateId === entry.updateId), entry)) || !current()) {
|
|
3279
|
+
throw new Error("Telegram routing input lifetime ACK was not retained.");
|
|
3280
|
+
}
|
|
3281
|
+
result.entries.forEach((entry, index) => { sources[index]!.entry = structuredClone(entry); });
|
|
3282
|
+
pendingSignal = true; launchDrain();
|
|
3283
|
+
return { issued: result.issued, lifetime: { ...result.entries[0]!.routingInput! } };
|
|
3284
|
+
};
|
|
2818
3285
|
const drain = async (
|
|
2819
3286
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2820
3287
|
): Promise<TelegramUpdateWorkerDrainResult> => {
|
|
@@ -2825,10 +3292,15 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2825
3292
|
let scheduledRetryAtMs: number | undefined;
|
|
2826
3293
|
try {
|
|
2827
3294
|
snapshot = deps.journal.read();
|
|
2828
|
-
scheduledRetryAtMs = refreshJournalState(snapshot, expectedOwner);
|
|
3295
|
+
scheduledRetryAtMs = await refreshJournalState(snapshot, expectedOwner);
|
|
3296
|
+
// One complete validated baseline per generation, before batching or execution.
|
|
3297
|
+
// Replayed v1 entries do not prove that a prior process never forwarded them.
|
|
3298
|
+
expectedOwner.startupUpdateIds ??= new Set(snapshot.entries.map(entry => entry.updateId));
|
|
2829
3299
|
} catch (error) {
|
|
2830
3300
|
return blockWithFailure("journal-read", "journal-read", error);
|
|
2831
3301
|
}
|
|
3302
|
+
if (await reconcileRoutingInputs(snapshot, expectedOwner)) continue;
|
|
3303
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) return "aborted";
|
|
2832
3304
|
const nowMs = getNowMs();
|
|
2833
3305
|
const entries: TelegramUpdateWorkerJournalSnapshot["entries"][number][] =
|
|
2834
3306
|
[];
|
|
@@ -2885,7 +3357,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2885
3357
|
transition("idle");
|
|
2886
3358
|
return "idle";
|
|
2887
3359
|
}
|
|
2888
|
-
const completedUpdateIds: number[] = [];
|
|
3360
|
+
const completedUpdateIds: { updateId: number; expectedSource?: TelegramDeferredSourceEvidence; spent?: true }[] = [];
|
|
2889
3361
|
let snapshotInvalidated = false;
|
|
2890
3362
|
for (const entry of entries) {
|
|
2891
3363
|
const priorExecutions = unsettledExecutionsByUpdateId.get(entry.updateId);
|
|
@@ -2907,9 +3379,92 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2907
3379
|
break;
|
|
2908
3380
|
}
|
|
2909
3381
|
if (entry.preApprovalExcluded === true) {
|
|
2910
|
-
completedUpdateIds.push(entry.updateId);
|
|
3382
|
+
completedUpdateIds.push({ updateId: entry.updateId });
|
|
2911
3383
|
continue;
|
|
2912
3384
|
}
|
|
3385
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) && entry.state === "pending" && deps.journal.inspectPendingRetention) {
|
|
3386
|
+
const authorityResult = checkAuthority(expectedOwner, entry.updateId);
|
|
3387
|
+
if (authorityResult) return authorityResult;
|
|
3388
|
+
let retained: TelegramUpdateJournalPendingRetentionEvidence | undefined;
|
|
3389
|
+
let unverifiable = false;
|
|
3390
|
+
try { retained = deps.journal.inspectPendingRetention(entry); }
|
|
3391
|
+
catch (error) {
|
|
3392
|
+
unverifiable = true;
|
|
3393
|
+
recordRuntimeEvent(error, { phase: "pending-retention-inspection", updateId: entry.updateId });
|
|
3394
|
+
}
|
|
3395
|
+
if (retained || unverifiable) {
|
|
3396
|
+
// A copy is protection evidence only. A fresh owner must explicitly retry
|
|
3397
|
+
// cancellation; neither startup nor a malformed copy may dispatch the source.
|
|
3398
|
+
claims.set(entry.updateId, "abandoning");
|
|
3399
|
+
const journalBindingKey = retained?.journalBindingKey ?? deps.getJournalBindingKey?.();
|
|
3400
|
+
if (journalBindingKey) deferredSources.set(entry.updateId, { entry, journalBindingKey });
|
|
3401
|
+
transition("deferred", entry.updateId);
|
|
3402
|
+
continue;
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) && (entry.state === "pending" || entry.state === "retry-wait")) {
|
|
3406
|
+
const historical = expectedOwner.startupUpdateIds.has(entry.updateId);
|
|
3407
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
3408
|
+
// Classifiers and handler projections receive no mutable alias to CAS evidence.
|
|
3409
|
+
if (journalBindingKey) deferredSources.set(entry.updateId, { entry: structuredClone(entry), journalBindingKey,
|
|
3410
|
+
...(historical ? { historical: true } : {}) });
|
|
3411
|
+
const classifyPending = historical && entry.state === "pending" ? deps.shouldReviewHistoricalInput : deps.shouldHoldPendingInput;
|
|
3412
|
+
if (classifyPending) {
|
|
3413
|
+
const checkHistoricalAuthority = (): TelegramUpdateWorkerDrainResult | undefined => {
|
|
3414
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) return "aborted";
|
|
3415
|
+
if (deps.getJournalBindingKey?.() !== journalBindingKey || deps.isContextCurrent?.(expectedOwner.ctx) === false) {
|
|
3416
|
+
transition("blocked", entry.updateId, "authority-lost");
|
|
3417
|
+
return "blocked";
|
|
3418
|
+
}
|
|
3419
|
+
return checkAuthority(expectedOwner, entry.updateId);
|
|
3420
|
+
};
|
|
3421
|
+
const beforeClassification = checkHistoricalAuthority();
|
|
3422
|
+
if (beforeClassification) return beforeClassification;
|
|
3423
|
+
let review: boolean | "retain";
|
|
3424
|
+
try { review = await classifyPending(structuredClone(entry), expectedOwner.ctx, expectedOwner.controller.signal); }
|
|
3425
|
+
catch (error) {
|
|
3426
|
+
const authorityResult = checkHistoricalAuthority();
|
|
3427
|
+
if (authorityResult) return authorityResult;
|
|
3428
|
+
return blockWithFailure("execution", "historical-review-classification", error, entry.updateId);
|
|
3429
|
+
}
|
|
3430
|
+
const authorityResult = checkHistoricalAuthority();
|
|
3431
|
+
if (authorityResult) return authorityResult;
|
|
3432
|
+
if (typeof review !== "boolean" && !(historical && entry.state === "pending" && review === "retain"))
|
|
3433
|
+
return blockWithFailure("execution", "historical-review-classification",
|
|
3434
|
+
new TelegramUpdateAdmissionOutcomeError("Historical classification must return a boolean or retain verdict."), entry.updateId);
|
|
3435
|
+
// Classification may await a domain read; accepted/changed source evidence wins.
|
|
3436
|
+
try {
|
|
3437
|
+
const current = deps.journal.read();
|
|
3438
|
+
if (current.version !== snapshot.version || JSON.stringify(current.entries.find(candidate => candidate.updateId === entry.updateId)) !== JSON.stringify(entry)) {
|
|
3439
|
+
snapshotInvalidated = true;
|
|
3440
|
+
break;
|
|
3441
|
+
}
|
|
3442
|
+
} catch (error) { return blockWithFailure("journal-read", "historical-source-recheck", error, entry.updateId); }
|
|
3443
|
+
if (review === "retain") {
|
|
3444
|
+
claims.set(entry.updateId, "retained");
|
|
3445
|
+
transition("deferred", entry.updateId);
|
|
3446
|
+
continue;
|
|
3447
|
+
}
|
|
3448
|
+
if (review && historical && spendHistorical && classifyPending === deps.shouldReviewHistoricalInput) {
|
|
3449
|
+
completedUpdateIds.push({ updateId: entry.updateId, spent: true });
|
|
3450
|
+
continue;
|
|
3451
|
+
}
|
|
3452
|
+
if (review) {
|
|
3453
|
+
claims.set(entry.updateId, "historical");
|
|
3454
|
+
transition("deferred", entry.updateId);
|
|
3455
|
+
continue;
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
if (entry.state === "pending" && entry.routingInput) {
|
|
3459
|
+
if (historical && spendHistorical) {
|
|
3460
|
+
completedUpdateIds.push({ updateId: entry.updateId, spent: true });
|
|
3461
|
+
continue;
|
|
3462
|
+
}
|
|
3463
|
+
claims.set(entry.updateId, "historical");
|
|
3464
|
+
transition("deferred", entry.updateId);
|
|
3465
|
+
continue;
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
2913
3468
|
clearRetryTimer();
|
|
2914
3469
|
transition("executing", entry.updateId);
|
|
2915
3470
|
const execution = await executeWithinOwner(expectedOwner, entry.update);
|
|
@@ -2958,7 +3513,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2958
3513
|
}
|
|
2959
3514
|
for (const sourceUpdateId of receipt.sourceUpdateIds) claims.set(sourceUpdateId, "queued");
|
|
2960
3515
|
try {
|
|
2961
|
-
publishCommittedQueueReceipt(receipt, outcome.queueReceipt.queueOwner, expectedOwner
|
|
3516
|
+
await publishCommittedQueueReceipt(receipt, outcome.queueReceipt.queueOwner, expectedOwner);
|
|
2962
3517
|
} catch (error) {
|
|
2963
3518
|
return blockWithFailure("invalid-outcome", "queue-receipt-publish", error,
|
|
2964
3519
|
entry.updateId);
|
|
@@ -2975,7 +3530,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2975
3530
|
if (postExecutionAuthority) return postExecutionAuthority;
|
|
2976
3531
|
const claimableUpdateIds = new Set<number>([
|
|
2977
3532
|
entry.updateId,
|
|
2978
|
-
...claims.
|
|
3533
|
+
...[...claims].filter(([, claim]) => claim !== "abandoning" && claim !== "historical" && claim !== "retained").map(([id]) => id),
|
|
2979
3534
|
]);
|
|
2980
3535
|
let outcome: TelegramUpdateAdmissionOutcome;
|
|
2981
3536
|
try {
|
|
@@ -2993,7 +3548,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2993
3548
|
);
|
|
2994
3549
|
}
|
|
2995
3550
|
if (outcome.kind === "deferred") {
|
|
2996
|
-
|
|
3551
|
+
if (outcome.routingReview && !deferredSources.get(entry.updateId)?.historical) {
|
|
3552
|
+
return blockWithFailure("invalid-outcome", "historical-review-source", new TelegramUpdateAdmissionOutcomeError("Historical review needs an exact startup source."), entry.updateId);
|
|
3553
|
+
}
|
|
3554
|
+
claims.set(entry.updateId, outcome.routingReview ? "historical" : "deferred");
|
|
2997
3555
|
transition("deferred", entry.updateId);
|
|
2998
3556
|
continue;
|
|
2999
3557
|
}
|
|
@@ -3003,7 +3561,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3003
3561
|
completedUpdateIds,
|
|
3004
3562
|
);
|
|
3005
3563
|
if (completionResult) return completionResult;
|
|
3006
|
-
const queuedResult = commitQueuedOutcome(
|
|
3564
|
+
const queuedResult = await commitQueuedOutcome(
|
|
3007
3565
|
expectedOwner,
|
|
3008
3566
|
entry.updateId,
|
|
3009
3567
|
outcome,
|
|
@@ -3015,7 +3573,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3015
3573
|
snapshotInvalidated = true;
|
|
3016
3574
|
break;
|
|
3017
3575
|
}
|
|
3018
|
-
completedUpdateIds.push(entry.updateId);
|
|
3576
|
+
completedUpdateIds.push({ updateId: entry.updateId, ...(outcome.expectedSource ? { expectedSource: outcome.expectedSource } : {}) });
|
|
3019
3577
|
}
|
|
3020
3578
|
const completionResult = commitCompletedBatch(
|
|
3021
3579
|
expectedOwner,
|
|
@@ -3037,6 +3595,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3037
3595
|
launchDrain = (): void => {
|
|
3038
3596
|
const expectedOwner = owner;
|
|
3039
3597
|
if (!expectedOwner || drainPromise) return;
|
|
3598
|
+
let startupKey: string | undefined;
|
|
3599
|
+
if (onHeldSourcesPrepared) {
|
|
3600
|
+
try { startupKey = deps.getJournalBindingKey?.(); } catch { /* Missing startup identity grants no notification. */ }
|
|
3601
|
+
}
|
|
3602
|
+
let lastResult: TelegramUpdateWorkerDrainResult | undefined;
|
|
3040
3603
|
const run = async (): Promise<void> => {
|
|
3041
3604
|
while (
|
|
3042
3605
|
pendingSignal &&
|
|
@@ -3046,7 +3609,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3046
3609
|
pendingSignal = false;
|
|
3047
3610
|
// Keep newer wakes in this run so waitForDrain also covers them. A failure
|
|
3048
3611
|
// observed after signal() still keeps its latch; this loop never clears it.
|
|
3049
|
-
await drain(expectedOwner);
|
|
3612
|
+
lastResult = await drain(expectedOwner);
|
|
3050
3613
|
}
|
|
3051
3614
|
};
|
|
3052
3615
|
const operation = run();
|
|
@@ -3056,6 +3619,21 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3056
3619
|
drainPromise = undefined;
|
|
3057
3620
|
if (pendingSignal && !expectedOwner.controller.signal.aborted) {
|
|
3058
3621
|
launchDrain();
|
|
3622
|
+
} else if (lastResult === "idle" && startupKey && onHeldSourcesPrepared && !expectedOwner.heldSourcesPreparedIssued) {
|
|
3623
|
+
try {
|
|
3624
|
+
const ctx = expectedOwner.ctx, key = startupKey;
|
|
3625
|
+
const isCurrent = () => owner === expectedOwner && !blocked && !expectedOwner.controller.signal.aborted &&
|
|
3626
|
+
deps.getJournalBindingKey?.() === key && deps.isContextCurrent?.(ctx) !== false && deps.hasAuthority(ctx);
|
|
3627
|
+
if (!isCurrent()) return;
|
|
3628
|
+
expectedOwner.heldSourcesPreparedIssued = true;
|
|
3629
|
+
// Do not couple lifecycle/stop or the worker drain to a held controller/API reply.
|
|
3630
|
+
void Promise.resolve().then(() => isCurrent() ? onHeldSourcesPrepared({ ctx,
|
|
3631
|
+
journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent }) : undefined).catch(error => {
|
|
3632
|
+
try { deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" }); } catch { /* Diagnostic only. */ }
|
|
3633
|
+
});
|
|
3634
|
+
} catch (error) {
|
|
3635
|
+
try { deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" }); } catch { /* Diagnostic only. */ }
|
|
3636
|
+
}
|
|
3059
3637
|
}
|
|
3060
3638
|
}
|
|
3061
3639
|
};
|
|
@@ -3072,6 +3650,73 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3072
3650
|
drainPromise = tracked;
|
|
3073
3651
|
};
|
|
3074
3652
|
|
|
3653
|
+
const abandonSource = (input: Parameters<NonNullable<TelegramUpdateWorkerRuntime<TContext>["abandonDeferred"]>>[0],
|
|
3654
|
+
historicalSource?: ReturnType<typeof deferredSources.get>): TelegramUpdateJournalPendingAbandonmentResult | undefined => {
|
|
3655
|
+
const expectedOwner = owner;
|
|
3656
|
+
const source = deferredSources.get(input.updateId);
|
|
3657
|
+
const abandonPending = deps.journal.abandonPending;
|
|
3658
|
+
const isCurrent = () => {
|
|
3659
|
+
if (!expectedOwner || owner !== expectedOwner || input.signal.aborted ||
|
|
3660
|
+
expectedOwner.controller.signal !== input.signal || !source ||
|
|
3661
|
+
source.entry.routingInput?.phase === "selected" || (source.historical && historicalSource !== source) ||
|
|
3662
|
+
(claims.get(input.updateId) !== "deferred" && claims.get(input.updateId) !== "abandoning" &&
|
|
3663
|
+
!(claims.get(input.updateId) === "historical" && historicalSource === source))) return false;
|
|
3664
|
+
return input.isCurrent() && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3665
|
+
deps.getJournalBindingKey?.() === source.journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
3666
|
+
};
|
|
3667
|
+
if (!source || !abandonPending || !isCurrent()) return undefined;
|
|
3668
|
+
// Retain this claim on failure, including unknown commit: never reopen dispatch.
|
|
3669
|
+
claims.set(input.updateId, "abandoning");
|
|
3670
|
+
try {
|
|
3671
|
+
const result = abandonPending({ ...input, entry: source.entry,
|
|
3672
|
+
journalBindingKey: source.journalBindingKey, isCurrent });
|
|
3673
|
+
claims.delete(input.updateId);
|
|
3674
|
+
deferredSources.delete(input.updateId);
|
|
3675
|
+
state.journalEntryCount = result.entryCount;
|
|
3676
|
+
state.journalSerializedBytes = result.serializedBytes;
|
|
3677
|
+
transition("idle");
|
|
3678
|
+
pendingSignal = true;
|
|
3679
|
+
launchDrain();
|
|
3680
|
+
return result;
|
|
3681
|
+
} catch (error) {
|
|
3682
|
+
recordRuntimeEvent(error, { phase: "pending-abandonment", updateId: input.updateId });
|
|
3683
|
+
updateClaimCounts();
|
|
3684
|
+
notifyStateChange();
|
|
3685
|
+
throw error;
|
|
3686
|
+
}
|
|
3687
|
+
};
|
|
3688
|
+
|
|
3689
|
+
const inspectRecovery = (input: TelegramDeferredAbandonmentRecoveryRequest & { signal: AbortSignal },
|
|
3690
|
+
kind: "abandoning" | "historical"): TelegramDeferredAbandonmentRecoveryPage | undefined => {
|
|
3691
|
+
const expectedOwner = owner;
|
|
3692
|
+
if (!expectedOwner) return undefined;
|
|
3693
|
+
const { signal, journalBindingKey, isCurrent: callerIsCurrent, afterUpdateId } = input;
|
|
3694
|
+
const isOwnerCurrent = () => owner === expectedOwner && expectedOwner.controller.signal === signal && !signal.aborted;
|
|
3695
|
+
const isCurrent = () => isOwnerCurrent() && callerIsCurrent() && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3696
|
+
deps.getJournalBindingKey?.() === journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
3697
|
+
if (!isCurrent() || !deps.journal.abandonPending || !deps.journal.inspectPendingRetention ||
|
|
3698
|
+
(afterUpdateId !== undefined && (!Number.isSafeInteger(afterUpdateId) || afterUpdateId < 0))) return undefined;
|
|
3699
|
+
const matches = [...deferredSources].filter(([id, source]) => claims.get(id) === kind &&
|
|
3700
|
+
source.journalBindingKey === journalBindingKey && (afterUpdateId === undefined || id > afterUpdateId))
|
|
3701
|
+
.sort(([a], [b]) => a - b);
|
|
3702
|
+
const sources = matches.slice(0, TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT).map(([updateId, source]) => {
|
|
3703
|
+
let committed: TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
3704
|
+
return { original: structuredClone(source.entry), retry(authority: TelegramDeferredUpdateAbandonmentAuthority) {
|
|
3705
|
+
const authorized = () => isOwnerCurrent() && authority.isCurrent() && isCurrent();
|
|
3706
|
+
if (!authorized()) return undefined;
|
|
3707
|
+
if (committed) return { ...committed, duplicate: true };
|
|
3708
|
+
const stillProtected = () => authorized() && (claims.get(updateId) === kind || claims.get(updateId) === "abandoning") &&
|
|
3709
|
+
deferredSources.get(updateId) === source;
|
|
3710
|
+
if (!stillProtected()) return undefined;
|
|
3711
|
+
committed = abandonSource({ ...authority, updateId, signal, isCurrent: stillProtected }, source);
|
|
3712
|
+
return committed;
|
|
3713
|
+
} };
|
|
3714
|
+
});
|
|
3715
|
+
return isCurrent() ? { sources,
|
|
3716
|
+
...(matches.length > TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT ? { nextAfterUpdateId: matches[TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT - 1]![0] } : {}) } : undefined;
|
|
3717
|
+
};
|
|
3718
|
+
|
|
3719
|
+
|
|
3075
3720
|
return {
|
|
3076
3721
|
start(ctx) {
|
|
3077
3722
|
if (owner) {
|
|
@@ -3082,6 +3727,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3082
3727
|
return;
|
|
3083
3728
|
}
|
|
3084
3729
|
clearRetryTimer();
|
|
3730
|
+
clearRoutingTimer();
|
|
3085
3731
|
const nowMs = getNowMs();
|
|
3086
3732
|
const generation = ++nextGeneration;
|
|
3087
3733
|
const queueOwnerIdentity = resolveQueueOwnerIdentity(ctx, generation);
|
|
@@ -3092,6 +3738,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3092
3738
|
queueOwnerIdentity,
|
|
3093
3739
|
};
|
|
3094
3740
|
claims.clear();
|
|
3741
|
+
deferredSources.clear();
|
|
3095
3742
|
committedQueueReceipts.clear();
|
|
3096
3743
|
blocked = false;
|
|
3097
3744
|
pendingSignal = true;
|
|
@@ -3104,6 +3751,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3104
3751
|
state.journalSerializedBytes = 0;
|
|
3105
3752
|
state.oldestAdmittedAtMs = undefined;
|
|
3106
3753
|
state.deferredClaimCount = 0;
|
|
3754
|
+
delete state.abandoningClaimCount;
|
|
3755
|
+
delete state.historicalClaimCount;
|
|
3107
3756
|
state.queuedClaimCount = 0;
|
|
3108
3757
|
state.foreignQueuedCount = 0;
|
|
3109
3758
|
delete state.foreignQueuedOwner;
|
|
@@ -3133,7 +3782,47 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3133
3782
|
pendingSignal = true;
|
|
3134
3783
|
launchDrain();
|
|
3135
3784
|
},
|
|
3136
|
-
|
|
3785
|
+
armRoutingInput(input) { return mutateRoutingSource(input, false)?.lifetime; },
|
|
3786
|
+
selectRoutingInput(input) { return mutateRoutingSource(input, true)?.issued === true; },
|
|
3787
|
+
inspectDeferredSource(input) {
|
|
3788
|
+
const expectedOwner = owner;
|
|
3789
|
+
const source = deferredSources.get(input.updateId);
|
|
3790
|
+
const current = (): boolean => {
|
|
3791
|
+
if (!expectedOwner || owner !== expectedOwner || input.signal.aborted || expectedOwner.controller.signal !== input.signal ||
|
|
3792
|
+
!source || deferredSources.get(input.updateId) !== source || claims.get(input.updateId) !== "deferred" ||
|
|
3793
|
+
deps.getJournalBindingKey?.() !== source.journalBindingKey || deps.isContextCurrent?.(expectedOwner.ctx) === false ||
|
|
3794
|
+
!deps.hasAuthority(expectedOwner.ctx)) return false;
|
|
3795
|
+
return isQueueOwnerIdentityCurrent(expectedOwner);
|
|
3796
|
+
};
|
|
3797
|
+
if (!current()) return undefined;
|
|
3798
|
+
const snapshot = deps.journal.read();
|
|
3799
|
+
const entry = snapshot.entries.find(value => value.updateId === input.updateId);
|
|
3800
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) || !entry ||
|
|
3801
|
+
JSON.stringify(entry) !== JSON.stringify(source!.entry) || !current()) return undefined;
|
|
3802
|
+
return { journalBindingKey: source!.journalBindingKey, ...createTelegramUpdateJournalEntryDigest(entry) };
|
|
3803
|
+
},
|
|
3804
|
+
isHistoricalSource(input) {
|
|
3805
|
+
const source = deferredSources.get(input.updateId);
|
|
3806
|
+
const isCurrent = () => owner?.controller.signal === input.signal && !input.signal.aborted &&
|
|
3807
|
+
source?.historical === true && deferredSources.get(input.updateId) === source;
|
|
3808
|
+
if (!isCurrent()) return false;
|
|
3809
|
+
// Domain eligibility observes original evidence, never mutable handler projections.
|
|
3810
|
+
return (!input.matchesOriginal || input.matchesOriginal(structuredClone(source!.entry)) === true) && isCurrent();
|
|
3811
|
+
},
|
|
3812
|
+
supportsDeferredAbandonment(input) {
|
|
3813
|
+
const expectedOwner = owner;
|
|
3814
|
+
const source = deferredSources.get(input.updateId);
|
|
3815
|
+
if (!expectedOwner || input.signal.aborted || expectedOwner.controller.signal !== input.signal ||
|
|
3816
|
+
!source || source.historical || input.journalBindingKey !== source.journalBindingKey ||
|
|
3817
|
+
!deps.journal.abandonPending || !deps.journal.inspectPendingRetention ||
|
|
3818
|
+
claims.get(input.updateId) === "queued") return false;
|
|
3819
|
+
return deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3820
|
+
deps.getJournalBindingKey?.() === source.journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
3821
|
+
},
|
|
3822
|
+
abandonDeferred(input) { return abandonSource(input); },
|
|
3823
|
+
inspectAbandoning(input) { return inspectRecovery(input, "abandoning"); },
|
|
3824
|
+
inspectHistorical(input) { return inspectRecovery(input, "historical"); },
|
|
3825
|
+
async settleDeferred(input) {
|
|
3137
3826
|
const expectedOwner = owner;
|
|
3138
3827
|
if (
|
|
3139
3828
|
!expectedOwner ||
|
|
@@ -3154,10 +3843,19 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3154
3843
|
return;
|
|
3155
3844
|
}
|
|
3156
3845
|
const claim = claims.get(input.updateId);
|
|
3846
|
+
if (claim === "abandoning" || claim === "historical" || claim === "retained") return;
|
|
3157
3847
|
if (input.outcome.kind === "complete") {
|
|
3158
3848
|
// Expiry may retire only a still-deferred source, never accepted queue work.
|
|
3159
3849
|
if (claim !== "deferred") return;
|
|
3160
|
-
|
|
3850
|
+
let completion: TelegramUpdateAdmissionOutcome;
|
|
3851
|
+
try { completion = validateTelegramUpdateAdmissionOutcome(input.outcome, input.updateId, new Set([input.updateId])); }
|
|
3852
|
+
catch (error) {
|
|
3853
|
+
blockWithFailure("invalid-outcome", "late-invalid-completion", error, input.updateId);
|
|
3854
|
+
return;
|
|
3855
|
+
}
|
|
3856
|
+
if (completion.kind !== "complete") return;
|
|
3857
|
+
const result = commitCompletedBatch(expectedOwner, [{ updateId: input.updateId,
|
|
3858
|
+
...(completion.expectedSource ? { expectedSource: completion.expectedSource } : {}) }]);
|
|
3161
3859
|
if (!result) transition("idle", input.updateId);
|
|
3162
3860
|
return;
|
|
3163
3861
|
}
|
|
@@ -3182,7 +3880,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3182
3880
|
outcome = validateTelegramUpdateAdmissionOutcome(
|
|
3183
3881
|
input.outcome,
|
|
3184
3882
|
input.updateId,
|
|
3185
|
-
new Set([input.updateId, ...claims.
|
|
3883
|
+
new Set([input.updateId, ...[...claims].filter(([, claim]) => claim !== "abandoning" && claim !== "historical" && claim !== "retained").map(([id]) => id)]),
|
|
3186
3884
|
);
|
|
3187
3885
|
} catch (error) {
|
|
3188
3886
|
blockWithFailure(
|
|
@@ -3217,11 +3915,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3217
3915
|
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3218
3916
|
return;
|
|
3219
3917
|
}
|
|
3220
|
-
const
|
|
3221
|
-
|
|
3222
|
-
input.updateId,
|
|
3223
|
-
outcome,
|
|
3224
|
-
);
|
|
3918
|
+
const publication = commitQueuedOutcome(expectedOwner, input.updateId, outcome);
|
|
3919
|
+
const result = typeof publication === "string" ? publication : await publication;
|
|
3225
3920
|
if (result === "blocked") {
|
|
3226
3921
|
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3227
3922
|
return;
|
|
@@ -3229,7 +3924,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3229
3924
|
if (result === "aborted") return;
|
|
3230
3925
|
transition("queued", input.updateId);
|
|
3231
3926
|
},
|
|
3232
|
-
settleCustodied(input) {
|
|
3927
|
+
async settleCustodied(input) {
|
|
3233
3928
|
const expectedOwner = owner;
|
|
3234
3929
|
if (!expectedOwner || expectedOwner.controller.signal !== input.signal || input.signal.aborted)
|
|
3235
3930
|
return;
|
|
@@ -3277,7 +3972,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3277
3972
|
}
|
|
3278
3973
|
for (const sourceUpdateId of receipt.sourceUpdateIds) claims.set(sourceUpdateId, "queued");
|
|
3279
3974
|
try {
|
|
3280
|
-
publishCommittedQueueReceipt(receipt, input.result.queueReceipt.queueOwner, expectedOwner
|
|
3975
|
+
await publishCommittedQueueReceipt(receipt, input.result.queueReceipt.queueOwner, expectedOwner);
|
|
3281
3976
|
} catch (error) {
|
|
3282
3977
|
blockWithFailure("invalid-outcome", "queue-receipt-publish", error, input.updateId);
|
|
3283
3978
|
return;
|
|
@@ -3289,7 +3984,19 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3289
3984
|
const committed = getCurrentQueueReceipt(receipt);
|
|
3290
3985
|
return committed ? { ...committed.queueOwner } : undefined;
|
|
3291
3986
|
},
|
|
3292
|
-
|
|
3987
|
+
getQueueReceiptSettlementOwner(receipt, ctx, reason) {
|
|
3988
|
+
const expectedOwner = owner, committed = committedQueueReceipts.get(receipt.receiptId);
|
|
3989
|
+
if (!expectedOwner || expectedOwner.controller.signal.aborted ||
|
|
3990
|
+
!(deps.isContextCurrent?.(ctx) ?? expectedOwner.ctx === ctx) || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
3991
|
+
!committed || !areTelegramQueueAdmissionReceiptsEqual(committed.receipt, normalizeQueueReceipt(receipt)) ||
|
|
3992
|
+
(deps.getJournalBindingKey && committed.receipt.journalBindingKey !== deps.getJournalBindingKey()) ||
|
|
3993
|
+
!isTelegramUpdateJournalQueueOwnerProcess(committed.queueOwner, expectedOwner.queueOwnerIdentity)) return undefined;
|
|
3994
|
+
const attempt = scopedQueueCompletionAttempts.get(receipt.receiptId);
|
|
3995
|
+
if (attempt ? attempt.owner !== expectedOwner || attempt.reason !== reason || attempt.journalBindingKey !== deps.getJournalBindingKey?.()
|
|
3996
|
+
: !getCurrentQueueReceipt(receipt)) return undefined;
|
|
3997
|
+
return { ...committed.queueOwner };
|
|
3998
|
+
},
|
|
3999
|
+
completeQueueReceipts: function completeQueueReceipts(input): boolean {
|
|
3293
4000
|
const expectedOwner = owner;
|
|
3294
4001
|
if (
|
|
3295
4002
|
!expectedOwner ||
|
|
@@ -3298,8 +4005,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3298
4005
|
) {
|
|
3299
4006
|
return false;
|
|
3300
4007
|
}
|
|
3301
|
-
|
|
3302
|
-
const
|
|
4008
|
+
// An ACK permits only local completion reconciliation, never execution readiness.
|
|
4009
|
+
const receipts = input.sourceCompletions === undefined ? input.receipts.filter(receipt => !isQueueReceiptCompletionAcknowledged(receipt)) : input.receipts;
|
|
4010
|
+
if (receipts.length === 0) return input.sourceCompletions === undefined;
|
|
4011
|
+
const normalizedReceipts = receipts.map(normalizeQueueReceipt);
|
|
3303
4012
|
const receiptIds = new Set<string>();
|
|
3304
4013
|
const sourceUpdateIds = new Set<number>();
|
|
3305
4014
|
const queuedCompletions: Array<{
|
|
@@ -3343,11 +4052,87 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3343
4052
|
sourceUpdateIds.add(updateId);
|
|
3344
4053
|
}
|
|
3345
4054
|
}
|
|
4055
|
+
const completionBinding = deps.getJournalBindingKey?.();
|
|
4056
|
+
const ordinaryObserverBound = normalizedReceipts.every(receipt =>
|
|
4057
|
+
committedQueueReceipts.get(receipt.receiptId)!.receipt.journalBindingKey === completionBinding);
|
|
4058
|
+
const ordinaryCompletionCurrent = (): boolean => ordinaryObserverBound && owner === expectedOwner &&
|
|
4059
|
+
!expectedOwner.controller.signal.aborted && (deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) &&
|
|
4060
|
+
isQueueOwnerIdentityCurrent(expectedOwner) && deps.getJournalBindingKey?.() === completionBinding;
|
|
3346
4061
|
let removedUpdateIds: readonly number[];
|
|
4062
|
+
let scopedCompletionCurrent: (() => boolean) | undefined;
|
|
3347
4063
|
try {
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
)
|
|
4064
|
+
const prepared = normalizedReceipts.flatMap(receipt => committedQueueReceipts.get(receipt.receiptId)!.sourceCompletions ?? []);
|
|
4065
|
+
const sourceCompletions = input.sourceCompletions === undefined ? (prepared.length ? prepared : undefined) : input.sourceCompletions;
|
|
4066
|
+
if (input.sourceCompletions !== undefined && prepared.some(expected => !input.sourceCompletions!.some(value => isDeepStrictEqual(value, expected)))) {
|
|
4067
|
+
throw new Error("Telegram queued completion conflicts with its prepared source scopes.");
|
|
4068
|
+
}
|
|
4069
|
+
if (input.sourceCompletions === undefined && prepared.length && receipts.some(receipt => !requiredScopedQueueReceipts.has(receipt.receiptId))) {
|
|
4070
|
+
const scoped = receipts.filter(receipt => requiredScopedQueueReceipts.has(receipt.receiptId));
|
|
4071
|
+
const ordinary = receipts.filter(receipt => !requiredScopedQueueReceipts.has(receipt.receiptId));
|
|
4072
|
+
if (!scoped.length || !ordinary.length) throw new Error("Telegram mixed queue completion has incomplete scoped authority.");
|
|
4073
|
+
const binding = deps.getJournalBindingKey?.();
|
|
4074
|
+
const current = (): boolean => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
4075
|
+
(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) &&
|
|
4076
|
+
!!binding && deps.getJournalBindingKey?.() === binding;
|
|
4077
|
+
// Independent whole receipts need independent ACKs. Ordinary siblings must not acquire invented scopes.
|
|
4078
|
+
if (!current() || !completeQueueReceipts({ receipts: scoped, ctx: input.ctx, reason: input.reason }) || !current()) return false;
|
|
4079
|
+
return completeQueueReceipts({ receipts: ordinary, ctx: input.ctx, reason: input.reason });
|
|
4080
|
+
}
|
|
4081
|
+
if (sourceCompletions === undefined) {
|
|
4082
|
+
if (queuedCompletions.some(receipt => requiredScopedQueueReceipts.has(receipt.receiptId))) {
|
|
4083
|
+
throw new Error("Telegram scoped queue completion cannot downgrade required source scopes.");
|
|
4084
|
+
}
|
|
4085
|
+
removedUpdateIds = deps.journal.completeQueued(queuedCompletions).removedUpdateIds;
|
|
4086
|
+
} else {
|
|
4087
|
+
for (const receipt of queuedCompletions) requiredScopedQueueReceipts.add(receipt.receiptId);
|
|
4088
|
+
queuedCompletions.sort((a, b) => a.receiptId < b.receiptId ? -1 : a.receiptId > b.receiptId ? 1 : 0);
|
|
4089
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
4090
|
+
const current = (): boolean => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
4091
|
+
(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) &&
|
|
4092
|
+
deps.getJournalBindingKey?.() === journalBindingKey;
|
|
4093
|
+
if (!journalBindingKey || !current() || !completeQueuedExact || !inspectCompletion ||
|
|
4094
|
+
!Array.isArray(sourceCompletions) || input.sourceCompletions !== undefined && sourceCompletions.length !== sourceUpdateIds.size) {
|
|
4095
|
+
throw new Error("Telegram scoped queue completion requires full current source authority.");
|
|
4096
|
+
}
|
|
4097
|
+
scopedCompletionCurrent = current;
|
|
4098
|
+
const completions = normalizeTelegramQueueSourceCompletions(sourceCompletions, sourceUpdateIds, journalBindingKey, input.sourceCompletions !== undefined)
|
|
4099
|
+
.map(({ journalBindingKey: _binding, ...completion }) => completion);
|
|
4100
|
+
if (queuedCompletions.some(receipt => !completions.some(scope => receipt.sourceUpdateIds.includes(scope.updateId)))) {
|
|
4101
|
+
throw new Error("Telegram scoped queue completion requires a proven origin for every whole receipt.");
|
|
4102
|
+
}
|
|
4103
|
+
const previous = queuedCompletions.map(receipt => scopedQueueCompletionAttempts.get(receipt.receiptId));
|
|
4104
|
+
const retained = previous.find(attempt => attempt !== undefined);
|
|
4105
|
+
if (retained && (previous.some(attempt => attempt !== retained) || retained.owner !== expectedOwner ||
|
|
4106
|
+
retained.journalBindingKey !== journalBindingKey || retained.reason !== input.reason ||
|
|
4107
|
+
!isDeepStrictEqual(retained.receipts, queuedCompletions) || !isDeepStrictEqual(retained.completions, completions))) {
|
|
4108
|
+
throw new Error("Telegram scoped queue completion has conflicting issued authority.");
|
|
4109
|
+
}
|
|
4110
|
+
if (!retained) {
|
|
4111
|
+
const attempt = { owner: expectedOwner, journalBindingKey, reason: input.reason,
|
|
4112
|
+
receipts: queuedCompletions.map(receipt => ({ ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...receipt.queueOwner } })),
|
|
4113
|
+
completions: completions.map(completion => ({ ...completion })) };
|
|
4114
|
+
if (!current()) throw new Error("Telegram scoped queue completion authority changed before disposal.");
|
|
4115
|
+
// Mark before issuance: an exception may follow a committed rename, and cannot license another disposal.
|
|
4116
|
+
for (const receipt of queuedCompletions) scopedQueueCompletionAttempts.set(receipt.receiptId, attempt);
|
|
4117
|
+
const result = completeQueuedExact(queuedCompletions.map(receipt => ({ ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...receipt.queueOwner } })),
|
|
4118
|
+
completions.map(completion => ({ ...completion })));
|
|
4119
|
+
if (!current() || !isDeepStrictEqual(result.sourceCompletions, completions)) {
|
|
4120
|
+
throw new Error("Telegram scoped queue completion ACK was not confirmed.");
|
|
4121
|
+
}
|
|
4122
|
+
const removed = new Set(result.removedUpdateIds);
|
|
4123
|
+
if (removed.size !== sourceUpdateIds.size || [...sourceUpdateIds].some(id => !removed.has(id))) {
|
|
4124
|
+
throw new Error("Telegram scoped queue completion did not remove every source.");
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
4127
|
+
for (const completion of completions) {
|
|
4128
|
+
if (!current() || !isDeepStrictEqual(inspectCompletion({ ...completion }), completion) || !current()) {
|
|
4129
|
+
throw new Error("Telegram scoped queue completion ACK was not retained under current authority.");
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
// Every immutable whole receipt has a scoped queued-origin witness; native queued ACK continuity proves its complete removal.
|
|
4133
|
+
// Unscoped siblings receive only receipt acknowledgement, never a source marker or absence-based proof.
|
|
4134
|
+
removedUpdateIds = [...sourceUpdateIds];
|
|
4135
|
+
}
|
|
3351
4136
|
} catch (error) {
|
|
3352
4137
|
blockWithFailure(
|
|
3353
4138
|
"journal-write",
|
|
@@ -3370,12 +4155,14 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3370
4155
|
);
|
|
3371
4156
|
return false;
|
|
3372
4157
|
}
|
|
3373
|
-
|
|
4158
|
+
receipts.forEach((receipt, index) => {
|
|
3374
4159
|
acknowledgedQueueReceiptCompletions.set(receipt, normalizedReceipts[index]!);
|
|
3375
4160
|
});
|
|
3376
4161
|
for (const updateId of sourceUpdateIds) claims.delete(updateId);
|
|
3377
4162
|
for (const receiptId of receiptIds) {
|
|
3378
4163
|
committedQueueReceipts.delete(receiptId);
|
|
4164
|
+
requiredScopedQueueReceipts.delete(receiptId);
|
|
4165
|
+
scopedQueueCompletionAttempts.delete(receiptId);
|
|
3379
4166
|
}
|
|
3380
4167
|
const completedUpdateIds = [...sourceUpdateIds];
|
|
3381
4168
|
state.lastCompletedUpdateId = Math.max(
|
|
@@ -3389,8 +4176,19 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3389
4176
|
);
|
|
3390
4177
|
updateClaimCounts();
|
|
3391
4178
|
notifyStateChange();
|
|
3392
|
-
|
|
3393
|
-
|
|
4179
|
+
if (scopedCompletionCurrent) for (const receipt of normalizedReceipts) {
|
|
4180
|
+
if (!scopedCompletionCurrent()) break;
|
|
4181
|
+
try { observeQueuedCompletion?.(normalizeQueueReceipt(receipt), input.ctx); }
|
|
4182
|
+
catch (error) { recordRuntimeEvent(error, { phase: "queue-receipt-completion-observer", receiptId: receipt.receiptId }); }
|
|
4183
|
+
}
|
|
4184
|
+
// Ordinary queued disposal is source completion too; notify only after a whole positive ACK and fresh origin authority.
|
|
4185
|
+
if (!scopedCompletionCurrent) for (const updateId of completedUpdateIds) {
|
|
4186
|
+
try {
|
|
4187
|
+
if (!ordinaryCompletionCurrent()) break;
|
|
4188
|
+
deps.onUpdateCompleted?.(updateId, input.ctx, completionBinding);
|
|
4189
|
+
} catch (error) { recordRuntimeEvent(error, { phase: "queue-source-completion-observer", updateId }); }
|
|
4190
|
+
}
|
|
4191
|
+
if (!scopedCompletionCurrent || scopedCompletionCurrent()) { pendingSignal = true; launchDrain(); }
|
|
3394
4192
|
return true;
|
|
3395
4193
|
},
|
|
3396
4194
|
async stop() {
|
|
@@ -3398,12 +4196,14 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3398
4196
|
if (!expectedOwner) return;
|
|
3399
4197
|
pendingSignal = false;
|
|
3400
4198
|
clearRetryTimer();
|
|
4199
|
+
clearRoutingTimer();
|
|
3401
4200
|
expectedOwner.controller.abort();
|
|
3402
4201
|
await drainPromise?.catch(() => undefined);
|
|
3403
4202
|
if (owner !== expectedOwner) return;
|
|
3404
4203
|
owner = undefined;
|
|
3405
4204
|
drainPromise = undefined;
|
|
3406
4205
|
claims.clear();
|
|
4206
|
+
deferredSources.clear();
|
|
3407
4207
|
committedQueueReceipts.clear();
|
|
3408
4208
|
blocked = false;
|
|
3409
4209
|
state.phase = "stopped";
|
|
@@ -3411,6 +4211,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3411
4211
|
state.currentUpdateId = undefined;
|
|
3412
4212
|
state.blockedReason = undefined;
|
|
3413
4213
|
state.deferredClaimCount = 0;
|
|
4214
|
+
delete state.abandoningClaimCount;
|
|
4215
|
+
delete state.historicalClaimCount;
|
|
3414
4216
|
state.queuedClaimCount = 0;
|
|
3415
4217
|
state.foreignQueuedCount = 0;
|
|
3416
4218
|
delete state.foreignQueuedOwner;
|
|
@@ -3429,8 +4231,9 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3429
4231
|
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
3430
4232
|
notifyStateChange();
|
|
3431
4233
|
},
|
|
3432
|
-
waitForDrain() {
|
|
3433
|
-
|
|
4234
|
+
async waitForDrain() {
|
|
4235
|
+
await (drainPromise ?? Promise.resolve());
|
|
4236
|
+
await Promise.allSettled([...pendingQueuePublications.values()].map(value => value.task));
|
|
3434
4237
|
},
|
|
3435
4238
|
getState() {
|
|
3436
4239
|
return getTelegramUpdateWorkerStateSnapshot(state);
|
|
@@ -3647,6 +4450,14 @@ export interface TelegramUpdateAdmissionHandleDeps<
|
|
|
3647
4450
|
},
|
|
3648
4451
|
) => void | Promise<void>;
|
|
3649
4452
|
onLateOutcomeError?: (error: unknown, updateId: number) => void;
|
|
4453
|
+
abandonDeferred?: TelegramUpdateWorkerRuntime<TContext>["abandonDeferred"];
|
|
4454
|
+
armRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["armRoutingInput"];
|
|
4455
|
+
selectRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["selectRoutingInput"];
|
|
4456
|
+
supportsDeferredAbandonment?: TelegramUpdateWorkerRuntime<TContext>["supportsDeferredAbandonment"];
|
|
4457
|
+
inspectAbandoning?: TelegramUpdateWorkerRuntime<TContext>["inspectAbandoning"];
|
|
4458
|
+
inspectHistorical?: TelegramUpdateWorkerRuntime<TContext>["inspectHistorical"];
|
|
4459
|
+
inspectDeferredSource?: TelegramUpdateWorkerRuntime<TContext>["inspectDeferredSource"];
|
|
4460
|
+
isHistoricalSource?: TelegramUpdateWorkerRuntime<TContext>["isHistoricalSource"];
|
|
3650
4461
|
}
|
|
3651
4462
|
|
|
3652
4463
|
function mergeTelegramReportedAdmissionOutcome(
|
|
@@ -3656,7 +4467,17 @@ function mergeTelegramReportedAdmissionOutcome(
|
|
|
3656
4467
|
): TelegramUpdateAdmissionOutcome {
|
|
3657
4468
|
if (!current || current.kind === "deferred") return next;
|
|
3658
4469
|
if (next.kind === "deferred") return current;
|
|
3659
|
-
if (current.kind === "complete" && next.kind === "complete")
|
|
4470
|
+
if (current.kind === "complete" && next.kind === "complete") {
|
|
4471
|
+
if (!current.expectedSource) return next;
|
|
4472
|
+
if (!next.expectedSource) return current;
|
|
4473
|
+
if (current.expectedSource.updateId === next.expectedSource.updateId &&
|
|
4474
|
+
current.expectedSource.journalBindingKey === next.expectedSource.journalBindingKey &&
|
|
4475
|
+
current.expectedSource.sourceSha256 === next.expectedSource.sourceSha256) {
|
|
4476
|
+
if (!current.expectedSource.completionSha256) return next;
|
|
4477
|
+
if (!next.expectedSource.completionSha256 || current.expectedSource.completionSha256 === next.expectedSource.completionSha256) return current;
|
|
4478
|
+
}
|
|
4479
|
+
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${updateId} reported conflicting completion sources.`);
|
|
4480
|
+
}
|
|
3660
4481
|
if (current.kind === "queued" && next.kind === "queued" &&
|
|
3661
4482
|
areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
|
|
3662
4483
|
throw new TelegramUpdateAdmissionOutcomeError(
|
|
@@ -4650,13 +5471,21 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4650
5471
|
let nextExecutionGeneration = 0;
|
|
4651
5472
|
return async (update, ctx, signal) => {
|
|
4652
5473
|
const generation = ++nextExecutionGeneration;
|
|
5474
|
+
let suspended = false;
|
|
5475
|
+
let routingClaims = 0;
|
|
5476
|
+
let abandoned: TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
5477
|
+
let routingLifetime: TelegramUpdateJournalRoutingInput | undefined;
|
|
5478
|
+
let routingSourceIds: readonly number[] | undefined;
|
|
5479
|
+
let routingClockUnknown = false;
|
|
5480
|
+
let routingSelectionAttempted = false;
|
|
5481
|
+
let routingSelectionConfirmed = false;
|
|
4653
5482
|
const execution: TelegramUpdateExecutionFence = {
|
|
4654
5483
|
generation,
|
|
4655
5484
|
updateId: update.update_id,
|
|
4656
5485
|
signal,
|
|
4657
|
-
isCurrent: () => !signal.aborted,
|
|
5486
|
+
isCurrent: () => !signal.aborted && !suspended,
|
|
4658
5487
|
assertCurrent() {
|
|
4659
|
-
if (signal.aborted) {
|
|
5488
|
+
if (signal.aborted || suspended) {
|
|
4660
5489
|
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
4661
5490
|
}
|
|
4662
5491
|
},
|
|
@@ -4666,9 +5495,18 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4666
5495
|
execution.assertCurrent();
|
|
4667
5496
|
if (verdict === "consume") return { kind: "complete" };
|
|
4668
5497
|
let immediate = true;
|
|
5498
|
+
let settlementReported = false;
|
|
4669
5499
|
let outcome: TelegramUpdateAdmissionOutcome | undefined;
|
|
4670
5500
|
const boundUpdate = bindTelegramUpdateExecutionFence(
|
|
4671
5501
|
bindTelegramUpdateAdmissionSource(update, (next) => {
|
|
5502
|
+
if (suspended || signal.aborted) return;
|
|
5503
|
+
if (next.kind === "deferred" && next.routingReview) {
|
|
5504
|
+
if (!immediate || settlementReported || routingClaims > 0 || !deps.isHistoricalSource?.({ updateId: update.update_id, signal })) {
|
|
5505
|
+
throw new TelegramUpdateAdmissionOutcomeError("Historical review cannot override accepted or selected work.");
|
|
5506
|
+
}
|
|
5507
|
+
suspended = true;
|
|
5508
|
+
}
|
|
5509
|
+
if (next.kind !== "deferred") settlementReported = true;
|
|
4672
5510
|
if (immediate) {
|
|
4673
5511
|
outcome = mergeTelegramReportedAdmissionOutcome(
|
|
4674
5512
|
outcome,
|
|
@@ -4683,13 +5521,14 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4683
5521
|
);
|
|
4684
5522
|
}
|
|
4685
5523
|
void Promise.resolve()
|
|
4686
|
-
.then(() =>
|
|
4687
|
-
|
|
5524
|
+
.then(() => {
|
|
5525
|
+
if (suspended || signal.aborted) return;
|
|
5526
|
+
return deps.onLateOutcome!(next, {
|
|
4688
5527
|
updateId: update.update_id,
|
|
4689
5528
|
ctx,
|
|
4690
5529
|
signal,
|
|
4691
|
-
})
|
|
4692
|
-
)
|
|
5530
|
+
});
|
|
5531
|
+
})
|
|
4693
5532
|
.catch((error) => {
|
|
4694
5533
|
try {
|
|
4695
5534
|
deps.onLateOutcomeError?.(error, update.update_id);
|
|
@@ -4697,13 +5536,64 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4697
5536
|
// Diagnostic sinks must not create an unhandled late Promise.
|
|
4698
5537
|
}
|
|
4699
5538
|
});
|
|
4700
|
-
})
|
|
5539
|
+
}, { abandon: (authority) => {
|
|
5540
|
+
const isCurrent = () => !signal.aborted && routingClaims === 0 && !settlementReported && authority.isCurrent();
|
|
5541
|
+
if (immediate || outcome?.kind !== "deferred" || !deps.abandonDeferred || !isCurrent()) return undefined;
|
|
5542
|
+
if (abandoned) return { ...abandoned, duplicate: true };
|
|
5543
|
+
const wasSuspended = suspended;
|
|
5544
|
+
suspended = true;
|
|
5545
|
+
// Failed/unknown publication keeps dispatch suspended for exact cancellation retry.
|
|
5546
|
+
// An ineligible request that attempted no publication remains inert.
|
|
5547
|
+
const result = deps.abandonDeferred({ ...authority, updateId: update.update_id, signal, isCurrent });
|
|
5548
|
+
if (result) abandoned = result;
|
|
5549
|
+
else suspended = wasSuspended;
|
|
5550
|
+
return result;
|
|
5551
|
+
}, inspectAbandoning: request => execution.isCurrent()
|
|
5552
|
+
? deps.inspectAbandoning?.({ ...request, signal }) : undefined,
|
|
5553
|
+
inspectHistorical: request => execution.isCurrent() ? deps.inspectHistorical?.({ ...request, signal }) : undefined,
|
|
5554
|
+
inspectSource: () => execution.isCurrent() && !immediate && !settlementReported
|
|
5555
|
+
? deps.inspectDeferredSource?.({ updateId: update.update_id, signal }) : undefined,
|
|
5556
|
+
isHistorical: matchesOriginal => !signal.aborted && deps.isHistoricalSource?.({ updateId: update.update_id, signal,
|
|
5557
|
+
...(matchesOriginal ? { matchesOriginal } : {}) }) === true,
|
|
5558
|
+
isHistoricalReviewHeld: () => !signal.aborted && suspended && outcome?.kind === "deferred" && outcome.routingReview === true,
|
|
5559
|
+
supportsAbandonment: journalBindingKey => !signal.aborted && !settlementReported &&
|
|
5560
|
+
deps.supportsDeferredAbandonment?.({ updateId: update.update_id, signal, journalBindingKey }) === true,
|
|
5561
|
+
armRoutingInput: (operatorUserId, sourceUpdateIds) => {
|
|
5562
|
+
execution.assertCurrent();
|
|
5563
|
+
if (settlementReported || routingClaims || suspended) return undefined;
|
|
5564
|
+
if (routingClockUnknown) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input clock publication is unconfirmed.");
|
|
5565
|
+
const ids = [...sourceUpdateIds];
|
|
5566
|
+
if (routingSourceIds && !isDeepStrictEqual(ids, routingSourceIds)) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input source membership changed.");
|
|
5567
|
+
try { routingLifetime = deps.armRoutingInput?.({ updateId: update.update_id, signal, operatorUserId, sourceUpdateIds: ids }); }
|
|
5568
|
+
catch (error) { routingClockUnknown = true; throw error; }
|
|
5569
|
+
if (routingLifetime) routingSourceIds = ids;
|
|
5570
|
+
return routingLifetime ? { ...routingLifetime } : undefined;
|
|
5571
|
+
},
|
|
5572
|
+
acquireRouting: (select = false, sourceUpdateIds) => {
|
|
5573
|
+
execution.assertCurrent();
|
|
5574
|
+
if (select && routingClockUnknown) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input clock publication is unconfirmed; selection is protected.");
|
|
5575
|
+
if (select && routingLifetime && !routingSelectionConfirmed) {
|
|
5576
|
+
if (routingSelectionAttempted) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing selection is unconfirmed; no replay is permitted.");
|
|
5577
|
+
if (!isDeepStrictEqual(sourceUpdateIds ?? [update.update_id], routingSourceIds)) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input selection changed its source membership.");
|
|
5578
|
+
routingSelectionAttempted = true;
|
|
5579
|
+
routingSelectionConfirmed = deps.selectRoutingInput?.({ updateId: update.update_id, signal,
|
|
5580
|
+
operatorUserId: routingLifetime.operatorUserId, sourceUpdateIds: routingSourceIds! }) === true;
|
|
5581
|
+
if (!routingSelectionConfirmed) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input choice is expired or unavailable.");
|
|
5582
|
+
}
|
|
5583
|
+
routingClaims += 1;
|
|
5584
|
+
let released = false;
|
|
5585
|
+
return () => {
|
|
5586
|
+
if (released) return;
|
|
5587
|
+
released = true;
|
|
5588
|
+
routingClaims -= 1;
|
|
5589
|
+
};
|
|
5590
|
+
} }),
|
|
4701
5591
|
execution,
|
|
4702
5592
|
);
|
|
4703
5593
|
try {
|
|
4704
5594
|
execution.assertCurrent();
|
|
4705
5595
|
await deps.defaultHandle(boundUpdate, ctx, execution);
|
|
4706
|
-
execution.assertCurrent();
|
|
5596
|
+
if (outcome?.kind !== "deferred" || !outcome.routingReview || signal.aborted) execution.assertCurrent();
|
|
4707
5597
|
} finally {
|
|
4708
5598
|
immediate = false;
|
|
4709
5599
|
}
|
|
@@ -4715,7 +5605,7 @@ export function createTelegramCustodiedUpdateAdmissionHandle<
|
|
|
4715
5605
|
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4716
5606
|
TContext,
|
|
4717
5607
|
>(deps: Omit<TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>,
|
|
4718
|
-
"onLateOutcome" | "onLateOutcomeError"> & {
|
|
5608
|
+
"onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
|
|
4719
5609
|
journal: TelegramCustodyExecutionJournal;
|
|
4720
5610
|
recipientBindingKey: string;
|
|
4721
5611
|
onLateOutcomeError(error: unknown, updateId: number): void;
|
|
@@ -4755,6 +5645,8 @@ const isQueueReceiptCompletionAcknowledged = (receipt: TelegramQueueAdmissionRec
|
|
|
4755
5645
|
|
|
4756
5646
|
export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
4757
5647
|
isItemReady: (item: TelegramQueueAdmissionItemLike) => boolean;
|
|
5648
|
+
getQueueReceiptSettlementOwner?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext,
|
|
5649
|
+
reason: TelegramQueueReceiptCompletionReason) => TelegramUpdateJournalQueueOwner | undefined;
|
|
4758
5650
|
getQueueReceiptOwner: (
|
|
4759
5651
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
4760
5652
|
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
@@ -4782,15 +5674,20 @@ export function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(
|
|
|
4782
5674
|
kind: "prompt" | "control" | "discard",
|
|
4783
5675
|
): boolean => {
|
|
4784
5676
|
const plan = new Map<TelegramQueueAdmissionSettlementRuntime<TContext>, TelegramQueueAdmissionReceiptLike[]>();
|
|
5677
|
+
const reason = kind === "prompt" ? "prompt-handoff" : kind === "control" ? "control-settlement" : "discard";
|
|
5678
|
+
const settlementOwner = (runtime: TelegramQueueAdmissionSettlementRuntime<TContext>, receipt: TelegramQueueAdmissionReceiptLike) =>
|
|
5679
|
+
runtime.getQueueReceiptSettlementOwner ? runtime.getQueueReceiptSettlementOwner(receipt, ctx, reason) :
|
|
5680
|
+
runtime.isItemReady({ admissionReceipts: [receipt] }) ? runtime.getQueueReceiptOwner(receipt) : undefined;
|
|
4785
5681
|
for (const receipt of items.flatMap(item => item.admissionReceipts ?? [])) {
|
|
4786
|
-
if (
|
|
4787
|
-
const candidates = runtimes.filter(runtime => runtime.
|
|
5682
|
+
if (isQueueReceiptCompletionAcknowledged(receipt)) continue;
|
|
5683
|
+
const candidates = runtimes.filter(runtime => runtime.getQueueReceiptSettlementOwner ? !!settlementOwner(runtime, receipt)
|
|
5684
|
+
: runtime.isItemReady({ admissionReceipts: [receipt] }));
|
|
4788
5685
|
const selected = candidates[0];
|
|
4789
5686
|
if (!selected) return false;
|
|
4790
5687
|
if (candidates.length > 1) {
|
|
4791
|
-
const owner = selected
|
|
5688
|
+
const owner = settlementOwner(selected, receipt);
|
|
4792
5689
|
if (!owner || candidates.some(runtime => {
|
|
4793
|
-
const candidate = runtime
|
|
5690
|
+
const candidate = settlementOwner(runtime, receipt);
|
|
4794
5691
|
return !candidate || !areTelegramUpdateJournalQueueOwnersEqual(owner, candidate);
|
|
4795
5692
|
})) return false;
|
|
4796
5693
|
}
|
|
@@ -4848,7 +5745,7 @@ export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
|
|
|
4848
5745
|
const receipts: TelegramQueueAdmissionReceiptLike[] = [];
|
|
4849
5746
|
for (const item of items) {
|
|
4850
5747
|
for (const receipt of item.admissionReceipts ?? []) {
|
|
4851
|
-
if (
|
|
5748
|
+
if (!isQueueReceiptCompletionAcknowledged(receipt)) receipts.push(receipt);
|
|
4852
5749
|
}
|
|
4853
5750
|
}
|
|
4854
5751
|
return receipts.length === 0 || worker.completeQueueReceipts({ receipts, ctx, reason });
|
|
@@ -4859,6 +5756,7 @@ export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
|
|
|
4859
5756
|
worker.isQueueReceiptCommitted,
|
|
4860
5757
|
),
|
|
4861
5758
|
getQueueReceiptOwner: worker.getQueueReceiptOwner,
|
|
5759
|
+
...(worker.getQueueReceiptSettlementOwner ? { getQueueReceiptSettlementOwner: worker.getQueueReceiptSettlementOwner } : {}),
|
|
4862
5760
|
onPromptHandedOff: (item, ctx) =>
|
|
4863
5761
|
complete([item], ctx, "prompt-handoff"),
|
|
4864
5762
|
onControlSettled: (item, ctx) =>
|
|
@@ -5246,6 +6144,7 @@ export interface TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<
|
|
|
5246
6144
|
listFollowers: () => readonly TelegramBusFollowerView[];
|
|
5247
6145
|
createRecipientJournalResolver: (
|
|
5248
6146
|
profileKey: string,
|
|
6147
|
+
sessionId: string,
|
|
5249
6148
|
) => (() => { recoveryKey: string } | undefined);
|
|
5250
6149
|
queueStore: {
|
|
5251
6150
|
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
@@ -5291,8 +6190,8 @@ export function createTelegramQueueHandoffReconciliationRuntimeAssembly<
|
|
|
5291
6190
|
canHandoffWithLeader: deps.canHandoffWithLeader,
|
|
5292
6191
|
listFollowers: deps.listFollowers,
|
|
5293
6192
|
createRecipientJournalBindingKey(recipient) {
|
|
5294
|
-
if (!recipient.profileKey) return undefined;
|
|
5295
|
-
return deps.createRecipientJournalResolver(recipient.profileKey)()
|
|
6193
|
+
if (!recipient.profileKey || !recipient.sessionId) return undefined;
|
|
6194
|
+
return deps.createRecipientJournalResolver(recipient.profileKey, recipient.sessionId)()
|
|
5296
6195
|
?.recoveryKey;
|
|
5297
6196
|
},
|
|
5298
6197
|
getQueuedItems: deps.queueStore.getQueuedItems,
|
|
@@ -5502,6 +6401,8 @@ export interface TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext> {
|
|
|
5502
6401
|
acquireSourceReference?: (
|
|
5503
6402
|
binding: TelegramUpdateAdmissionLifecycleJournalBinding,
|
|
5504
6403
|
) => () => void;
|
|
6404
|
+
/** Runs after the previous worker stopped and before the replacement worker exists; throwing refuses binding. */
|
|
6405
|
+
prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding) => void;
|
|
5505
6406
|
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
5506
6407
|
}
|
|
5507
6408
|
|
|
@@ -5558,8 +6459,8 @@ export interface TelegramUpdateAdmissionLifecycleRuntime<TContext>
|
|
|
5558
6459
|
|
|
5559
6460
|
export interface TelegramUpdateWorkerOwnerRuntime<TContext> {
|
|
5560
6461
|
getQueueOwnerIdentity: () => TelegramUpdateJournalQueueOwnerIdentity;
|
|
5561
|
-
onQueueReceiptCommitted: (receipt:
|
|
5562
|
-
onUpdateCompleted: (updateId: number, ctx: TContext) => void;
|
|
6462
|
+
onQueueReceiptCommitted: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext) => void;
|
|
6463
|
+
onUpdateCompleted: (updateId: number, ctx: TContext, journalBindingKey?: string) => void;
|
|
5563
6464
|
}
|
|
5564
6465
|
|
|
5565
6466
|
export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
|
|
@@ -5570,7 +6471,8 @@ export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
|
|
|
5570
6471
|
isContextCurrent: (ctx: TContext) => boolean;
|
|
5571
6472
|
dispatchNext: (ctx: TContext) => void;
|
|
5572
6473
|
requestQueueHandoffReconciliation: (ctx: TContext) => void;
|
|
5573
|
-
|
|
6474
|
+
afterQueueReceiptCommitted?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext) => void;
|
|
6475
|
+
afterUpdateCompleted?: (updateId: number, ctx: TContext, journalBindingKey?: string) => void;
|
|
5574
6476
|
}
|
|
5575
6477
|
|
|
5576
6478
|
export function createTelegramUpdateWorkerOwnerRuntime<TContext>(
|
|
@@ -5585,15 +6487,16 @@ export function createTelegramUpdateWorkerOwnerRuntime<TContext>(
|
|
|
5585
6487
|
sessionGeneration: deps.getSessionGeneration(),
|
|
5586
6488
|
};
|
|
5587
6489
|
},
|
|
5588
|
-
onQueueReceiptCommitted(
|
|
6490
|
+
onQueueReceiptCommitted(receipt, ctx) {
|
|
5589
6491
|
if (!deps.isContextCurrent(ctx)) return;
|
|
6492
|
+
deps.afterQueueReceiptCommitted?.(receipt, ctx);
|
|
5590
6493
|
deps.dispatchNext(ctx);
|
|
5591
6494
|
deps.requestQueueHandoffReconciliation(ctx);
|
|
5592
6495
|
},
|
|
5593
|
-
onUpdateCompleted(updateId, ctx) {
|
|
6496
|
+
onUpdateCompleted(updateId, ctx, journalBindingKey) {
|
|
5594
6497
|
if (!deps.isContextCurrent(ctx)) return;
|
|
5595
6498
|
deps.dispatchNext(ctx);
|
|
5596
|
-
deps.afterUpdateCompleted?.(updateId);
|
|
6499
|
+
deps.afterUpdateCompleted?.(updateId, ctx, journalBindingKey);
|
|
5597
6500
|
},
|
|
5598
6501
|
};
|
|
5599
6502
|
}
|
|
@@ -5625,6 +6528,7 @@ export function createTelegramUpdateAdmissionRuntimeBinding<TContext>(deps: {
|
|
|
5625
6528
|
let follower: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
5626
6529
|
let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
5627
6530
|
let inputCustodyBus: TelegramInputCustodyBusBindingRuntime<TContext> | undefined;
|
|
6531
|
+
const getActive = () => deps.isFollowerRegistered() ? follower : leader;
|
|
5628
6532
|
return {
|
|
5629
6533
|
bind(input) {
|
|
5630
6534
|
leader = input.leader;
|
|
@@ -5637,7 +6541,7 @@ export function createTelegramUpdateAdmissionRuntimeBinding<TContext>(deps: {
|
|
|
5637
6541
|
},
|
|
5638
6542
|
getLeader: () => leader,
|
|
5639
6543
|
getFollower: () => follower,
|
|
5640
|
-
getActive
|
|
6544
|
+
getActive,
|
|
5641
6545
|
getSettlement: () => settlement,
|
|
5642
6546
|
getInputCustodyBus: () => inputCustodyBus,
|
|
5643
6547
|
getLifecycleForJournalBinding(journalBindingKey) {
|
|
@@ -5743,6 +6647,7 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
|
|
|
5743
6647
|
binding.recoveryKey !== recoveryKey) {
|
|
5744
6648
|
throw new Error("Telegram update source binding changed during startup.");
|
|
5745
6649
|
}
|
|
6650
|
+
deps.prepareBinding?.(binding);
|
|
5746
6651
|
const release = deps.acquireSourceReference?.(binding);
|
|
5747
6652
|
const inputCustody = binding.journal.inputCustody;
|
|
5748
6653
|
try {
|
|
@@ -6043,6 +6948,8 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
|
|
|
6043
6948
|
isRegistered: () => boolean;
|
|
6044
6949
|
getGeneration: () => string | undefined;
|
|
6045
6950
|
prepareUpdateForExecution: (update: TUpdate) => TUpdate;
|
|
6951
|
+
/** Session succession before worker creation, under the exact generation-fenced binding. */
|
|
6952
|
+
prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding & { hasAuthority?: () => boolean }) => void;
|
|
6046
6953
|
};
|
|
6047
6954
|
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
6048
6955
|
}
|
|
@@ -6073,6 +6980,14 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
|
|
|
6073
6980
|
const executeUpdate = createTelegramUpdateAdmissionHandle<TUpdate, TContext>({
|
|
6074
6981
|
defaultHandle: deps.defaultHandle,
|
|
6075
6982
|
registry: deps.registry,
|
|
6983
|
+
abandonDeferred: input => worker?.abandonDeferred?.(input),
|
|
6984
|
+
armRoutingInput: input => worker?.armRoutingInput?.(input),
|
|
6985
|
+
selectRoutingInput: input => worker?.selectRoutingInput?.(input) === true,
|
|
6986
|
+
supportsDeferredAbandonment: input => worker?.supportsDeferredAbandonment?.(input) === true,
|
|
6987
|
+
inspectAbandoning: input => worker?.inspectAbandoning?.(input),
|
|
6988
|
+
inspectHistorical: input => worker?.inspectHistorical?.(input),
|
|
6989
|
+
inspectDeferredSource: input => worker?.inspectDeferredSource?.(input),
|
|
6990
|
+
isHistoricalSource: input => worker?.isHistoricalSource?.(input) === true,
|
|
6076
6991
|
onLateOutcome(outcome, details) {
|
|
6077
6992
|
worker?.settleDeferred({
|
|
6078
6993
|
updateId: details.updateId,
|
|
@@ -6155,6 +7070,7 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
|
|
|
6155
7070
|
getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
|
|
6156
7071
|
...(deps.acquireSourceReference ? { acquireSourceReference: binding =>
|
|
6157
7072
|
deps.acquireSourceReference!("follower", binding) } : {}),
|
|
7073
|
+
...(deps.follower.prepareBinding ? { prepareBinding: deps.follower.prepareBinding } : {}),
|
|
6158
7074
|
resolveBinding() {
|
|
6159
7075
|
const generation = deps.follower.getGeneration();
|
|
6160
7076
|
if (!deps.follower.isRegistered() || !generation) return undefined;
|
|
@@ -6179,6 +7095,11 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
|
|
|
6179
7095
|
getJournalBindingKey: () => binding.recoveryKey,
|
|
6180
7096
|
getRecipientBindingKey: () => binding.recipientBindingKey,
|
|
6181
7097
|
hasAuthority: () => binding.hasAuthority?.() ?? false,
|
|
7098
|
+
// Recipient custody is not an orphaned direct-bot source.
|
|
7099
|
+
shouldReviewHistoricalInput: undefined,
|
|
7100
|
+
shouldHoldPendingInput: undefined,
|
|
7101
|
+
spendHistoricalInput: undefined,
|
|
7102
|
+
onHeldSourcesPrepared: undefined,
|
|
6182
7103
|
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
6183
7104
|
});
|
|
6184
7105
|
},
|